Explorar o código

Merge branch 'codex/dataops-phase3-enterprise-readiness' of mxl_citu/DataOps-platform into master

mxl_citu hai 2 meses
pai
achega
ae347e33b1
Modificáronse 100 ficheiros con 22866 adicións e 201 borrados
  1. 1 0
      .dockerignore
  2. 3 0
      .gitignore
  3. 401 0
      .superpowers/sdd/task-4-report.md
  4. 412 0
      .superpowers/sdd/task-5-report.md
  5. 295 0
      .superpowers/sdd/task-6-report.md
  6. 260 0
      .superpowers/sdd/task-7-report.md
  7. 419 0
      .superpowers/sdd/task-8-report.md
  8. 50 9
      app/__init__.py
  9. 7 0
      app/api/data_development/__init__.py
  10. 2579 0
      app/api/data_development/routes.py
  11. 245 0
      app/api/data_development/semantic_routes.py
  12. 1 0
      app/api/data_factory/__init__.py
  13. 246 0
      app/api/data_factory/observability_routes.py
  14. 36 10
      app/api/data_flow/routes.py
  15. 76 21
      app/api/data_interface/routes.py
  16. 7 0
      app/api/data_rules/__init__.py
  17. 140 0
      app/api/data_rules/quality_routes.py
  18. 1075 0
      app/api/data_rules/routes.py
  19. 1 0
      app/api/data_service/__init__.py
  20. 302 0
      app/api/data_service/product_governance_routes.py
  21. 3 0
      app/api/data_source/__init__.py
  22. 299 0
      app/api/data_source/edge_routes.py
  23. 507 15
      app/api/data_source/routes.py
  24. 1 1
      app/api/knowledge_base/__init__.py
  25. 377 0
      app/api/knowledge_base/agent_governance_routes.py
  26. 200 7
      app/api/knowledge_base/routes.py
  27. 7 1
      app/api/meta_data/__init__.py
  28. 154 0
      app/api/meta_data/active_metadata.py
  29. 95 0
      app/api/meta_data/domain_templates.py
  30. 13 0
      app/api/system/__init__.py
  31. 166 0
      app/api/system/bi_ai_catalog_routes.py
  32. 648 0
      app/api/system/enterprise_identity.py
  33. 299 0
      app/api/system/governance_audit.py
  34. 137 0
      app/api/system/metering_showback_routes.py
  35. 140 0
      app/api/system/plugin_platform_routes.py
  36. 329 0
      app/api/system/responsibilities.py
  37. 280 0
      app/api/system/security_governance.py
  38. 207 0
      app/api/system/tenant_routes.py
  39. 182 0
      app/api/system/trusted_delivery.py
  40. 99 0
      app/api/system/trusted_delivery_controls.py
  41. 95 8
      app/api/system/users.py
  42. 327 0
      app/api/system/work_center.py
  43. 34 7
      app/commands/process_outbox.py
  44. 26 0
      app/commands/reconcile_data_research.py
  45. 37 0
      app/commands/reconcile_dataflow_creates.py
  46. 200 28
      app/config/config.py
  47. 121 0
      app/config/database_urls.py
  48. 15 0
      app/core/connectors/__init__.py
  49. 224 0
      app/core/connectors/bindings.py
  50. 32 0
      app/core/connectors/builtin/__init__.py
  51. 184 0
      app/core/connectors/builtin/database.py
  52. 63 0
      app/core/connectors/builtin/oracle.py
  53. 68 0
      app/core/connectors/builtin/postgresql.py
  54. 381 0
      app/core/connectors/builtin/rest_catalog.py
  55. 78 0
      app/core/connectors/builtin/sqlserver.py
  56. 84 0
      app/core/connectors/errors.py
  57. 379 0
      app/core/connectors/identity.py
  58. 52 0
      app/core/connectors/registry.py
  59. 513 0
      app/core/connectors/repository.py
  60. 573 0
      app/core/connectors/runtime.py
  61. 233 0
      app/core/connectors/sdk.py
  62. 39 0
      app/core/connectors/secrets.py
  63. 16 0
      app/core/connectors/store.py
  64. 144 0
      app/core/data_flow/create_reconciliation.py
  65. 542 94
      app/core/data_flow/dataflows.py
  66. 5 0
      app/core/data_research/__init__.py
  67. 161 0
      app/core/data_research/artifacts.py
  68. 78 0
      app/core/data_research/candidate_decisions.py
  69. 6 0
      app/core/data_research/catalog/__init__.py
  70. 74 0
      app/core/data_research/catalog/base.py
  71. 129 0
      app/core/data_research/catalog/execution.py
  72. 81 0
      app/core/data_research/catalog/models.py
  73. 49 0
      app/core/data_research/catalog/mysql.py
  74. 59 0
      app/core/data_research/catalog/postgresql.py
  75. 98 0
      app/core/data_research/catalog/service.py
  76. 122 0
      app/core/data_research/data_elements.py
  77. 270 0
      app/core/data_research/device_asset_repository.py
  78. 440 0
      app/core/data_research/device_assets.py
  79. 295 0
      app/core/data_research/device_entity_repository.py
  80. 737 0
      app/core/data_research/device_entity_resolution.py
  81. 766 0
      app/core/data_research/device_observability.py
  82. 359 0
      app/core/data_research/device_observability_repository.py
  83. 1001 0
      app/core/data_research/device_quality.py
  84. 478 0
      app/core/data_research/device_quality_repository.py
  85. 262 0
      app/core/data_research/device_semantic_repository.py
  86. 969 0
      app/core/data_research/device_semantics.py
  87. 160 0
      app/core/data_research/errors.py
  88. 3 0
      app/core/data_research/extractors/__init__.py
  89. 62 0
      app/core/data_research/extractors/base.py
  90. 96 0
      app/core/data_research/extractors/csv.py
  91. 67 0
      app/core/data_research/extractors/docx.py
  92. 78 0
      app/core/data_research/extractors/excel.py
  93. 47 0
      app/core/data_research/extractors/image.py
  94. 84 0
      app/core/data_research/extractors/pdf.py
  95. 33 0
      app/core/data_research/extractors/registry.py
  96. 41 0
      app/core/data_research/extractors/sql.py
  97. 73 0
      app/core/data_research/file_policy.py
  98. 333 0
      app/core/data_research/governance_metric_repository.py
  99. 159 0
      app/core/data_research/governance_metrics.py
  100. 32 0
      app/core/data_research/graph_projection.py

+ 1 - 0
.dockerignore

@@ -3,6 +3,7 @@
 .pytest_cache
 **/__pycache__
 **/*.pyc
+._*
 frontend/node_modules
 frontend/dist
 deployment

+ 3 - 0
.gitignore

@@ -32,6 +32,8 @@ wheels/
 # Large generated / local artifacts
 web_crawl_direct_test*.json
 designs/
+outputs/
+*.numbers
 
 # Virtual Environment
 .env
@@ -58,6 +60,7 @@ log/
 .env.test.local
 .env.production.local
 .known_hosts_dataops
+deploy/docker/edge-secrets/
 work/
 .worktrees/
 

+ 401 - 0
.superpowers/sdd/task-4-report.md

@@ -0,0 +1,401 @@
+# Task 4 report — SQLGlot executable vertical slice
+
+## Status
+
+Implemented the deployment-bound SQL vertical slice for PostgreSQL and MySQL.
+Logical DataFlow release remains a semantic, binding-free reference. Physical
+dataset bindings are consumed only by `BoundSqlPlanService`, which selects a
+registered concrete compiler and persists the resulting plan as `compiled`.
+The Runner still requires the exact bound plan and RuleVersion to be
+`published`; full test-evidence publication lifecycle hardening remains Task 7.
+
+## RED / GREEN evidence
+
+Initial RED command:
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/core/data_rules/test_sql_compiler.py \
+tests/runner/test_rule_sql.py
+```
+
+Initial RED result:
+
+```text
+18 failed in 1.38s
+```
+
+Representative expected failures:
+
+```text
+ModuleNotFoundError: No module named 'app.core.data_rules.compilers'
+ModuleNotFoundError: No module named 'app.runner.rule_sql'
+```
+
+Additional RED checks were added before each hardening fix:
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/core/data_rules/test_sql_compiler.py::test_repository_persists_bound_plan_only_for_one_deployment_linkage
+1 failed in 1.05s
+AttributeError: 'DataRuleRepository' object has no attribute 'persist_bound_component_plan'
+```
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/runner/test_rule_sql.py::test_sqlglot_rule_adapter_rejects_unimplemented_idempotency_strategy \
+tests/runner/test_rule_sql.py::test_rule_executor_matches_node_idempotency_to_persisted_component
+2 failed in 1.09s
+```
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/core/data_rules/test_sql_compiler.py::test_repository_persists_bound_plan_only_for_one_deployment_linkage
+1 failed in 0.88s
+Failed: DID NOT RAISE ValueError
+```
+
+This proved a compiled plan was not yet checked against the physical source
+and target relations stored for the deployment.
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/core/data_rules/test_sql_compiler.py::test_sql_compiler_fails_closed_for_unsupported_operations_and_binding_mismatch
+1 failed in 0.86s
+Failed: DID NOT RAISE ValueError
+```
+
+This proved `assert.on_failure=quarantine` was being accepted without a
+quarantine destination, so the compiler was tightened to support only
+`reject`.
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/runner/test_rule_sql.py::test_sqlglot_rule_adapter_verifies_and_executes_one_transaction
+1 failed in 0.98s
+```
+
+This proved affected-row counts were not a stable `rows_out` metric for
+idempotent upserts. The adapter now counts the compiled accepted-row query
+inside the same transaction before executing the write.
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/core/data_rules/test_sql_compiler.py::test_sql_compiler_rejects_unproven_operator_type_semantics
+5 failed in 0.90s
+```
+
+This proved text, fill, derive, map, and final output types required additional
+fail-closed compiler checks.
+
+Focused GREEN after the implementation and hardening:
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/core/data_rules/test_sql_compiler.py \
+tests/runner/test_rule_sql.py \
+tests/runner/test_rules.py
+33 passed in 0.84s
+```
+
+Related release/repository/bootstrap regression GREEN:
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/core/data_rules/test_release.py \
+tests/core/data_rules/test_data_rule_repository.py \
+tests/runner/test_bootstrap.py \
+tests/runner/test_rules.py \
+tests/core/data_rules/test_execution_contracts.py
+49 passed in 0.89s
+```
+
+## Real PostgreSQL and MySQL integration
+
+The source containers were already healthy on PostgreSQL port `25432` and
+MySQL port `23306`. The integration test:
+
+- creates only `task4_rule_source` and `task4_rule_target`;
+- inserts one accepted and one rejected customer;
+- compiles and persists a bound plan as `compiled`;
+- performs a real dialect preflight and records its successful evidence before
+  transitioning the in-test plan record to `published`;
+- executes through `RulePlanExecutor`;
+- verifies transformed target rows and exact `rows_in`, `rows_out`, and
+  `rows_rejected`;
+- repeats the upsert to prove idempotency for both dialects;
+- mutates the stored plan body without changing its hash and verifies
+  fail-closed execution;
+- drops only the two Task 4 tables in `finally`.
+
+Direct dialect integration result:
+
+```text
+TEST_DATABASE_URL=postgresql://dataops:dataops-test-password@127.0.0.1:15432/dataops \
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/integration/test_data_rule_sql_execution.py
+2 passed in 0.93s
+```
+
+Required vertical-slice command:
+
+```text
+TEST_DATABASE_URL=postgresql://dataops:dataops-test-password@127.0.0.1:15432/dataops \
+PYTHONPATH=. .venv/bin/pytest -q \
+tests/core/data_rules/test_sql_compiler.py \
+tests/runner/test_rule_sql.py \
+tests/integration/test_data_rule_sql_execution.py
+28 passed in 0.97s
+```
+
+## Full verification
+
+```text
+PYTHONPATH=. .venv/bin/pytest -q
+477 passed, 26 skipped, 59 subtests passed in 4.16s
+```
+
+```text
+.venv/bin/ruff check \
+app/core/data_rules/compilers \
+app/core/data_rules/release.py \
+app/core/data_rules/repository.py \
+app/runner/rule_sql.py \
+app/runner/rules.py \
+app/runner/bootstrap.py \
+tests/core/data_rules/test_sql_compiler.py \
+tests/runner/test_rule_sql.py \
+tests/runner/test_rules.py \
+tests/integration/test_data_rule_sql_execution.py
+All checks passed!
+```
+
+`git diff --check` produced no output.
+
+## Dependency and license
+
+- Added and installed `sqlglot==30.13.0`.
+- Installed package metadata reports `License-Expression: MIT`.
+- Installed package metadata reports `Requires-Python: >=3.9`.
+- The compiler provenance stored with bound plans is
+  `dataops-sqlglot-30.13.0`.
+
+## Files
+
+- `requirements.txt`
+- `app/core/data_rules/compilers/__init__.py`
+- `app/core/data_rules/compilers/base.py`
+- `app/core/data_rules/compilers/sql.py`
+- `app/core/data_rules/release.py`
+- `app/core/data_rules/repository.py`
+- `app/runner/rule_sql.py`
+- `app/runner/rules.py`
+- `app/runner/bootstrap.py`
+- `tests/core/data_rules/test_sql_compiler.py`
+- `tests/runner/test_rule_sql.py`
+- `tests/runner/test_rules.py`
+- `tests/integration/test_data_rule_sql_execution.py`
+- `.superpowers/sdd/task-4-report.md`
+
+## Self-review
+
+- All user identifiers are converted to quoted SQLGlot `Identifier` nodes only
+  after the closed identifier check. RuleSpec values become compiler-owned
+  named placeholders and never enter SQL text.
+- The compiler validates the published RuleVersion hash, server-owned input
+  and output snapshots, binding IDs, same datasource, relation kinds, access
+  modes, dialect, concrete timezone/collation/rounding/regex capabilities, and
+  final output field types.
+- The first executable operator slice covers `cast`, trim-only
+  `normalize_text`, portable `regex_replace`, type-compatible `fill_null`,
+  typed `filter`, typed `derive`, reject-only `assert`, deterministic
+  `deduplicate`, and string `map_values`. Unsupported or semantically
+  unprovable variants fail closed.
+- Bound-plan persistence re-parses the plan and proves its source relation,
+  target relation, datasource UID, dialect, binding IDs, and schema hashes
+  match one deployment owning the logical component.
+- Runner execution re-validates the closed plan and SQL AST, exact hash,
+  RuleVersion ID, datasource dialect/capabilities, write authorization, and
+  persisted component idempotency. Only AST-built `upsert` is implemented;
+  the other declared orchestration strategies fail closed at this adapter.
+- Raw input count, accepted output count, and the AST-built upsert execute
+  within one datasource transaction. Known commit failures return
+  `not_committed`; commit ambiguity returns `unknown`.
+- The incorrect `quality_check -> SQL write adapter` alias was removed.
+  `SqlGlotQualityPlanAdapter` explicitly fails closed until a read-only
+  quality plan contract is implemented.
+
+## Concerns / deferred work
+
+- Production publication and revocation of compiled bound plans, including
+  durable test evidence and approval transitions, remain Task 7. This task
+  deliberately persists only `compiled` and does not claim tested/published
+  production state.
+- Only same-datasource table/view-to-table SQL pushdown is supported. Cross
+  source, lookup joins, aggregate, mask, lower/upper Unicode normalization,
+  quarantine writes, and non-append output bindings fail closed.
+- `partition_replace` and `deduplication_key` remain valid orchestration
+  contract values but are not executable by this SQL adapter; only an exact
+  persisted `upsert` binding is accepted.
+- The datasource definition must carry server-owned
+  `sql_rule_capabilities`. Existing deployments without those concrete
+  capabilities will fail closed until Task 9 binds and validates them.
+- The explicit quality adapter is intentionally non-executable. A separate
+  read-only quality plan and evidence contract is needed before quality nodes
+  can run.
+
+---
+
+# Task 4 review-fix pass — canonical provenance and publication safety
+
+Date: 2026-07-23
+
+## Outcome
+
+All critical, important, and identifier-limit review findings were corrected.
+The bound SQL compiler now accepts only canonical identifiers at its public
+service boundary. RuleVersion, schema snapshots, deployment bindings, and the
+server SQL capability profile are loaded through one repository join before
+compilation. Caller-provided RuleSpec, schema, binding, or capability objects
+are no longer accepted.
+
+The plan attests the exact compiler version, RuleSpec hash, input/output schema
+snapshot IDs and hashes, and deployment binding IDs. Persistence compares
+those attestations against the canonical database rows. The runner loads and
+rechecks the same canonical RuleVersion and snapshots before dispatch.
+
+Logical release plans now persist as `compiled`, and released package plan
+references explicitly identify themselves as `plan_kind=semantic` and
+`status=compiled`. Physical plans use the explicit lifecycle
+`compiled -> tested -> published`; only successful `integration_preflight`
+evidence can produce `tested`, and only an attested tested plan whose
+RuleVersion remains published can be published. The runner rejects both
+compiled and tested plans.
+
+## Correctness and security changes
+
+- PostgreSQL upsert now proves that the requested idempotency column is an
+  exact single-column PRIMARY KEY or UNIQUE constraint using live server
+  metadata.
+- MySQL upsert proves the same exact key and rejects any alternate unique
+  index, preventing `ON DUPLICATE KEY UPDATE` from merging a different
+  logical row through another unique collision path.
+- SQL casts accept only `on_error=fail`; reject, quarantine, and warn fail
+  closed.
+- Final schema compatibility is exact. Numeric type changes require an
+  explicit cast.
+- Deduplication extends the requested ordering with every remaining projected
+  field and explicit null ordering, yielding a deterministic total value
+  order; fully identical rows remain equivalent.
+- The runner requires the exact supported `dataops-sqlglot-30.13.0`
+  attestation and validates a closed INSERT-SELECT SQLGlot AST subset.
+  Anonymous and non-allowlisted functions, joins, CTEs, set operations, and
+  other out-of-compiler nodes are rejected even if a malicious publisher
+  recomputes the plan hash.
+- PostgreSQL identifiers are bounded to 63 UTF-8 bytes and MySQL identifiers
+  to 64 UTF-8 bytes for schemas, tables, schema fields, expression fields,
+  step columns, and derived/deduplication fields.
+- A new migration adds `tested` to the durable execution-plan status
+  constraint.
+
+## TDD evidence
+
+The initial RED run produced the expected failures for the old caller-object
+API, canonical component-to-rule mismatch, and function-bearing plan.
+Additional regression tests cover non-fail cast actions, exact numeric types,
+total deduplication order, UTF-8 identifier byte limits, logical compiled
+status, registry availability before release allocation, canonical runner
+attestation tampering, and lifecycle evidence.
+
+Final verification:
+
+```text
+Focused compiler/repository/release/runner plus real database integration:
+63 passed
+
+Real PostgreSQL/MySQL execution and MySQL uniqueness failure cases:
+3 passed
+
+Full repository suite:
+496 passed, 26 skipped, 59 subtests passed
+
+Ruff on all changed Python and migration files:
+All checks passed!
+
+git diff --check:
+no output
+```
+
+## Remaining bounded concerns
+
+- The server capability profile is intentionally a closed policy profile for
+  the two supported dialects (`C`/POSIX for PostgreSQL and
+  `utf8mb4_0900_bin`/ICU for MySQL). A deployment with different concrete
+  datasource capabilities fails closed at runner dispatch.
+- Only same-datasource table/view-to-table SQL pushdown and exact-key upsert
+  are executable in this slice. Other write strategies and the read-only
+  quality executor remain fail-closed.
+
+---
+
+# Task 4 final review correction — compiled-only production boundary
+
+Date: 2026-07-23
+
+This section supersedes the earlier statement in this report that Task 4
+provided a production `compiled -> tested -> published` transition.
+
+## Boundary correction
+
+- Removed `record_bound_plan_test` and `publish_tested_bound_plan` from
+  `DataRuleRepository`. Task 4 exposes no production API that accepts
+  caller-authored execution evidence or promotes a plan.
+- Physical bound plans still persist only as `compiled`.
+- The production Runner remains published-only and rejects compiled plans.
+- The real-database integration test publishes only inside its explicitly
+  trusted in-memory test repository after a real preflight. This is not a
+  production publication surface.
+- Task 7 remains responsible for opaque server-created execution evidence and
+  the authoritative tested/publication state machine.
+
+## Additional fail-closed corrections
+
+- Integer division (`/`) is removed from PostgreSQL and MySQL V1 capability
+  support. Both dialect compilers reject a rule containing division; Polars
+  reference capability remains available for its defined decimal semantics.
+- Deterministic deduplication rejects a plan if any explicit or appended
+  total-order field has an unproven ordering type. JSON and binary regression
+  cases now fail compilation.
+- The already-applied `20260723_120` migration remains unchanged.
+  `20260723_130` pins the database status constraint to
+  `compiled`, `published`, and `revoked`; it does not add `tested`.
+  Its downgrade raises a clear `RuntimeError` because persisted compiled plans
+  make this status-contract migration intentionally forward-only.
+
+## Final TDD and verification evidence
+
+The RED run produced six expected failures:
+
+- production promotion API still present: 1
+- PostgreSQL/MySQL division still advertised: 2
+- JSON/binary deduplication accepted: 2
+- missing explicit forward-only migration downgrade: 1
+
+After the migration-chain correction, final verification was:
+
+```text
+Focused expressions/compiler/repository/release/runner/schema:
+89 passed
+
+Real PostgreSQL/MySQL integration:
+3 passed
+
+Full repository suite:
+501 passed, 26 skipped, 59 subtests passed
+
+Ruff on all changed Python and migration files:
+All checks passed!
+
+git diff --check:
+no output
+```

+ 412 - 0
.superpowers/sdd/task-5-report.md

@@ -0,0 +1,412 @@
+# Task 5 Report — Governed Polars cross-source execution and artifact handoff
+
+Date: 2026-07-23
+Branch: `codex/data-rule-execution-m3a-m5`
+Implementation commits before the third remediation: `69d83ed`, `f4d798b`,
+`8bffdb6`, `c152e74`. The third-remediation commit is the commit containing
+this report revision.
+
+`8bffdb6` is the final hash of the second remediation commit. It replaces the
+intermediate `a17bcd0` after restoring the historical M3A acceptance document
+byte-for-byte; no production or test behavior changed in that amend.
+
+Lifecycle boundary: physical Polars plans are created as `compiled`; Task 5
+does not add a production promotion API. The real integration inserts a
+published fixture only to exercise the existing Runner publication gate.
+
+## Final outcome
+
+Task 5 now provides a fail-closed PostgreSQL/MySQL → Parquet/MinIO → isolated
+Polars → cataloged Parquet path through the production repository, resolver,
+executor, HTTP token, and durable task-ledger combination.
+
+The final implementation includes:
+
+- closed JSON Polars plans and shared compile/runtime semantic validation;
+- typed cast targets carrying nullability, Decimal precision/scale, and
+  timestamptz timezone;
+- real rule-timezone semantics for `date()`, `timestamp()`, timestamp cast, and
+  timestamptz cast;
+- server-owned Parquet refs, digest validation, TTL enforcement, and bounded
+  metadata;
+- streamed MinIO staging to temporary files and trusted PyArrow footer
+  preflight before Polars scans;
+- all allocation-heavy Polars scans, expression evaluation, regex, join,
+  group/aggregate, counts, and output write in an isolated spawn child;
+- parent RSS monitoring, child OS `RLIMIT_AS`, bounded execution time, and
+  fail-closed worker errors;
+- durable `pending` → object upload → `ready` catalog handoff keyed by
+  `(correlation_id, binding_id, artifact_kind)`, with the exact attested
+  `binding_hash`;
+- bounded bidirectional reconciliation for pending, ready, missing, invalid,
+  and orphaned objects, plus the existing expiry cleanup;
+- minimal public artifact results without full schema fields;
+- standards-valid CycloneDX 1.5 runtime SBOM with deterministic offline schema
+  validation.
+
+The bound plan contains canonical RuleVersion, SchemaSnapshot, and
+DatasetBinding hashes plus `dataops-polars-1.42.1` provenance. It contains no
+Python source, pickle, callable, module, caller-supplied path, arbitrary URL,
+secret, or serialized Polars internal plan.
+
+## Second-remediation RED/GREEN evidence
+
+The second review was implemented as fail-first slices. Representative RED
+evidence captured during the work:
+
+- Immutable handoff tests failed before the catalog uniqueness changed from
+  digest-based uniqueness to the correlation/binding/kind invariant and before
+  retry cleanup/reuse existed.
+- Public-metadata tests failed while MinIO metadata still carried the base64
+  full schema contract and public results still exposed `schema_fields`.
+- Cleanup tests failed before a bounded production catalog cleanup entrypoint
+  deleted both the MinIO object and the exact PostgreSQL row.
+- Compression-bomb and tempfile-staging tests failed before PyArrow footer
+  row-group preflight and disk streaming existed.
+- Worker isolation tests failed before `app/runner/polars_worker.py` existed;
+  the first adapter reconstruction run then exposed one schema-order failure
+  (`1 failed`) because canonical fields are name-sorted while Parquet retains
+  physical column order. Validation was corrected to compare the closed field
+  set and exact per-field types.
+- Typed-cast RED:
+  `test_polars_cast_operations_carry_complete_typed_targets` failed
+  (`1 failed`) with missing `target_field`.
+- Timezone RED:
+  `test_polars_expression_date_and_timestamp_use_plan_timezone` failed
+  (`1 failed`) because `timestamp()` produced timezone-aware output for a
+  timezone-free timestamp contract.
+- The final full-suite pre-close run found one stale test monkeypatch
+  (`1 failed, 553 passed`) after in-memory `BytesIO` staging was removed; the
+  regression check was changed to assert the stage implementation contains no
+  `BytesIO`.
+- The first Ruff closeout found two style-only failures (unused `io`, nested
+  `with`); both were fixed before final verification.
+
+Second-remediation GREEN:
+
+- Focused compiler/adapter/worker/artifact/bootstrap/schema/SBOM command:
+  `57 passed in 4.17s`.
+- Final artifact/worker/runtime/SBOM recheck after the last portability and
+  timestamp-cast changes:
+  `30 passed in 3.92s`.
+- Final real cross-source + HTTP/ledger integration:
+  `1 passed in 2.03s`.
+- Final full suite:
+  `554 passed, 26 skipped, 59 subtests passed in 8.39s`.
+- Ruff across every changed Python file: `All checks passed!`.
+- `git diff --check`: passed.
+- `docker compose -f deploy/docker/docker-compose.yml config -q`: passed.
+
+The third remediation supersedes the second-remediation migration and
+handoff implementation described below wherever they conflict.
+
+## Third-remediation RED/GREEN evidence
+
+The third review was also implemented in fail-first slices:
+
+- schema source tests failed until committed migration 140 was restored
+  byte-for-byte from `f4d798b` and the state change moved to new migration
+  `20260723_150`;
+- reservation-order tests failed until the resolver inserted a durable
+  `pending` row before the first MinIO PUT;
+- crash-injection tests failed until reserve, upload, and finalize had distinct
+  failure semantics and commit-acknowledgement loss was rechecked;
+- stale-binding tests failed until reservation and finalization locked and
+  attested the canonical binding in the same database transaction;
+- reconciliation tests failed until missing pending rows, valid pending
+  objects, missing ready objects, invalid objects, old orphans, fresh objects,
+  and unsafe keys had separate bounded policies;
+- worker-start injection initially leaked raw startup errors and pipe/process
+  handles; it now returns a safe error and closes every created endpoint in a
+  unified `finally`.
+
+Third-remediation GREEN:
+
+- focused schema/artifact/handoff/bootstrap/worker/adapter plus both real
+  integrations: `49 passed in 5.62s`;
+- isolated temporary PostgreSQL migration test: real old 140 schema, old-format
+  row insertion, upgrade to head, migrated `ready` row and `binding_hash`,
+  same-digest retry, different-digest CAS conflict: passed;
+- real source PostgreSQL + source MySQL + platform PostgreSQL + MinIO +
+  isolated Polars + Flask HTTP + durable ledger: passed;
+- final full suite: `564 passed, 26 skipped, 59 subtests passed in 9.01s`;
+- Ruff across every changed Python file: passed;
+- `git diff --check`: passed;
+- `docker compose -f deploy/docker/docker-compose.yml config -q`: passed;
+- `git diff f4d798b -- migrations/versions/20260723_140_rule_run_artifacts.py`:
+  empty.
+
+## Fourth-remediation RED/GREEN evidence
+
+The fourth review closed concurrency and storage-classification gaps in
+reconciliation. Fail-first tests reproduced all four defects:
+
+- a one-second prepared TTL became zero after a microsecond of validation delay
+  because `int()` rounded the remaining interval down;
+- a pending snapshot raced with another finalizer, observed `UPDATE` rowcount
+  zero, and the old broad failure update downgraded the new ready row;
+- timeout, network, and authentication failures from MinIO were treated as
+  proof of an invalid object for both pending and ready catalog rows;
+- a same-size mutated Parquet payload with unchanged trusted-looking stat
+  metadata was finalized because reconciliation never streamed its content.
+
+The GREEN implementation and coverage now prove:
+
+- upload validation uses ceiling semantics for the absolute expiry interval, so
+  `ttl_seconds=1` remains valid while it has positive lifetime;
+- every failure transition is an expected-state CAS; a stale pending snapshot
+  cannot update a ready row;
+- a zero-row pending finalize rereads by immutable row id, accepts an exact
+  matching ready row, marks only a still-pending binding mismatch, and leaves
+  other current states unchanged;
+- confirmed `NotFound` is distinct from deterministic metadata/content/schema
+  invalidity and from temporary timeout/network/AccessDenied failures;
+- temporary failures during either stat or streaming download preserve both
+  pending and ready catalog state and never delete the object;
+- pending recovery streams and hashes the object and validates the Parquet
+  footer and exact schema before ready; same-size content mutation fails;
+- a freshly observed ready row with deterministically invalid content uses a
+  ready-only CAS to transition to failed.
+
+Fourth-remediation verification:
+
+- focused artifact/handoff tests: `36 passed`;
+- focused schema/artifact/handoff/bootstrap/worker/adapter plus real old140
+  migration and real cross-source integration: `63 passed in 5.21s`;
+- final full suite: `578 passed, 26 skipped, 59 subtests passed`;
+- Ruff, `git diff --check`, Compose config validation, immutable-140 diff, and
+  unchanged-150 diff: passed.
+
+## Durable artifact handoff, attestation, and reconciliation
+
+Migration `20260723_140` is historical and unchanged. It retains its original
+unique constraint:
+
+```text
+UNIQUE (correlation_id, binding_id, artifact_digest)
+```
+
+New forward-only migration `20260723_150`:
+
+- adds `binding_hash`, `handoff_status`, `ready_at`, `failed_at`,
+  `failure_code`, and `updated_at`;
+- migrates old rows to `ready` with the current canonical binding hash;
+- refuses unattested or conflicting old rows;
+- discovers and drops only the exact old-140 digest uniqueness constraint;
+- adds named uniqueness on
+  `(correlation_id, binding_id, artifact_kind)` and a reconciliation index.
+
+`PostgresArtifactResolver.publish_path` implements the durable protocol:
+
+1. validate the worker path and reserve a server-generated exact object key;
+2. in one transaction, lock the canonical binding with `FOR SHARE`, attest its
+   hash/object/access contract, and insert the exact `pending` catalog row;
+3. upload only to that reserved key and revalidate the stored object;
+4. in a new transaction, re-lock and re-attest the binding, then atomically
+   finalize only the matching pending row to `ready`.
+
+Resolution sees only `ready` rows whose persisted binding hash still equals the
+canonical binding hash. Same-triple/same-digest retries reuse the ready object
+without uploading; different digests fail before upload. Upload failure removes
+the exact object and pending row. A lost finalize acknowledgement is rechecked:
+confirmed ready is success; unconfirmed state returns
+`commit_outcome="unknown"` and deliberately does not delete the possibly
+cataloged object.
+
+`reconcile-rule-artifacts --limit --grace-seconds` processes only aged,
+bounded candidates. It deletes missing pending rows, finalizes valid pending
+objects only while the binding still matches, marks ready rows failed when
+their object is missing or invalid, deletes invalid pending objects safely, and
+deletes only old safe-key orphans after the grace period. Fresh objects,
+referenced objects, and malformed/unowned keys are retained.
+
+Reconciliation does not trust object stat metadata as content attestation.
+Before a pending row becomes ready it streams the exact object to a temporary
+file and verifies digest, Parquet footer row/uncompressed bounds, and the exact
+catalog schema. Failure transitions include the snapshot's expected state in
+their `WHERE` clause. If a pending finalization loses its race, the resolver
+rereads the row and treats only an exact matching ready handoff as success.
+Confirmed absence and deterministic invalidity follow catalog repair policy;
+temporary MinIO authentication, server, transport, and timeout errors cause no
+catalog or object mutation in that reconciliation pass.
+
+`cleanup-rule-artifacts --limit` uses a bounded
+`FOR UPDATE SKIP LOCKED` selection, removes each expired store object, and
+deletes its exact catalog row in the same database transaction scope. Unit
+tests cover the CLI and object+row cleanup behavior.
+
+The local database had an earlier applied empty copy of migration 140 with a
+manually altered constraint. It was explicitly not accepted as migration
+evidence. After verifying zero rows, only that empty table was rebuilt by
+rewinding its Alembic marker to 130 and running the formal
+130 → original-140 → 150 chain. Independently, the automated acceptance test
+creates and drops a fresh temporary database and proves old140 → head without
+manual ALTER.
+
+## Resource enforcement and worker isolation
+
+### Pre-allocation artifact checks
+
+`ArtifactStore.stage`:
+
+1. validates the server-owned ref, object stat, content type, compressed size,
+   metadata digest, row count, schema digest, and TTL;
+2. streams MinIO response chunks to a `NamedTemporaryFile` while hashing;
+3. validates the downloaded digest;
+4. reads the Parquet footer with `pyarrow.parquet.ParquetFile`;
+5. rejects excessive footer row count or summed row-group uncompressed size;
+6. checks only the lazy physical schema in the main process;
+7. yields the path and always removes the temporary file.
+
+Full schema contracts are no longer base64-encoded into MinIO metadata. MinIO
+metadata contains only bounded digest/count/expiry/size fields, with a
+2,048-character aggregate ceiling. PostgreSQL remains the canonical store for
+the complete schema contract.
+
+### Isolated execution
+
+The adapter stages paths but does not collect input frames. A spawn child:
+
+- warms Polars before establishing its runtime baseline;
+- applies `RLIMIT_AS` relative to baseline virtual memory;
+- validates the plan again;
+- scans input and lookup Parquet;
+- executes every allowlisted operation;
+- validates output row/type/nullability contracts;
+- writes the output Parquet path.
+
+The parent independently watches incremental RSS with psutil, kills the child
+on limit breach, enforces a hard timeout, and returns only bounded metrics.
+Worker PID is deliberately not exposed in the public result.
+
+Pipe creation, process construction, `process.start()`, message handling, and
+resource enforcement now share one guarded lifecycle. Startup errors are
+sanitized, both pipe endpoints are closed when created, and a started process
+is killed/joined/closed from the unified `finally`.
+
+Tests prove:
+
+- worker PID differs from the Runner PID;
+- a 128 MiB allocation fails under an 8 MiB allowance;
+- a highly compressed Parquet payload is rejected from footer bounds before
+  scan exposure;
+- regex expansion, high-cardinality group/aggregate, and wide lookup join peak
+  allocations fail inside the isolated worker.
+
+Portability boundary: spawn and psutil are portable, but the OS hard limit
+requires Unix `resource.RLIMIT_AS`. Linux/macOS are supported targets. On a
+platform without `RLIMIT_AS`, the worker fails closed instead of silently
+running without an OS memory boundary.
+
+## Typed casts and timezone semantics
+
+Each compiled cast operation now contains an exact `target_field`. The shared
+validator rejects a missing field, an unknown/extra shape, a target name/type
+mismatch, incomplete Decimal precision/scale, or missing timestamptz timezone.
+The worker no longer falls back to inferring cast details from output fields.
+
+Executable tests verify:
+
+- string → `Decimal(12, 2)`;
+- string → `Datetime(time_zone="Asia/Shanghai")`;
+- offset timestamp → rule-local timezone-free `timestamp`;
+- `date(offset_value)` converts to the rule timezone before selecting the
+  calendar date;
+- `timestamp(offset_value)` converts to the rule timezone and removes timezone
+  metadata to match the platform's timezone-free timestamp type.
+
+## Real production combination
+
+The integration discovers runtime values from
+`deploy/docker/docker-compose.yml`; credentials are not copied into source.
+
+- source PostgreSQL: live customer input;
+- source MySQL: live segment lookup;
+- MinIO: input, lookup, and output Parquet objects;
+- platform PostgreSQL: snapshots, bindings, published plan, artifact catalog,
+  and durable task ledger;
+- Polars spawn worker: normalize → lookup join → assert → deduplicate;
+- Flask `/v1/tasks/execute`: signed short-lived task token;
+- `PostgresTaskLedger`: single-use claim and committed success record.
+
+Evidence:
+
+- 4 input rows → 2 output rows;
+- 1 assertion reject;
+- 1 deduplicated row;
+- exact enriched values verified after reread;
+- direct repeat reuses the stable output ref;
+- conflicting output digest fails before a new object is uploaded;
+- HTTP result returns the same stable output ref;
+- replaying the same token returns HTTP 409;
+- durable ledger records `success` / `committed`;
+- public result contains no `schema_fields`;
+- exactly three catalog rows and three correlation-scoped MinIO objects remain
+  before cleanup.
+
+The test finally removes only its exact correlation prefix, source tables,
+ledger record, catalog entries, plan/binding/deployment/rule rows, and schema
+snapshots, and asserts the MinIO prefix is empty.
+
+## CycloneDX 1.5 SBOM
+
+`docs/security/data-rule-runtime-sbom.json` now uses standard CycloneDX fields:
+
+- `licenses`;
+- `externalReferences`;
+- `properties`;
+- component `bom-ref` and dependency relationships.
+
+Pinned runtime components:
+
+- `polars==1.42.1` — MIT;
+- `polars-runtime-32==1.42.1` — MIT;
+- `pyarrow==21.0.0` — Apache-2.0;
+- `psutil==5.9.8` — BSD-3-Clause;
+- `minio==7.2.10` — Apache-2.0.
+
+The test validates the document with `jsonschema` against vendored official
+CycloneDX 1.5, SPDX, and JSF schemas. The official files' SHA-256 hashes are
+asserted before validation, so the check is deterministic and offline.
+
+## Key files in the final remediation
+
+Production:
+
+- `app/core/data_rules/compilers/polars.py`
+- `app/runner/artifacts.py`
+- `app/runner/polars_worker.py`
+- `app/runner/rule_polars.py`
+- `app/runner/bootstrap.py`
+- `migrations/versions/20260723_140_rule_run_artifacts.py`
+- `migrations/versions/20260723_150_rule_artifact_handoff_state.py`
+- `requirements.txt`
+- `docs/security/data-rule-runtime-sbom.json`
+- `docs/security/cyclonedx-1.5-schema/`
+
+Tests:
+
+- `tests/core/data_rules/test_polars_compiler.py`
+- `tests/runner/test_artifacts.py`
+- `tests/runner/test_artifact_handoff.py`
+- `tests/runner/test_polars_worker.py`
+- `tests/runner/test_rule_polars.py`
+- `tests/runner/test_bootstrap.py`
+- `tests/integration/test_data_rule_polars_execution.py`
+- `tests/integration/test_rule_artifact_migration_upgrade.py`
+- `tests/test_data_rule_runtime_sbom.py`
+- `tests/test_data_rule_schema.py`
+
+## Remaining boundaries
+
+- Task 7 still owns test-evidence recording and plan publication.
+  `RulePlanExecutor` continues to refuse `compiled` plans in the published
+  runtime path.
+- `quality_check` remains fail-closed through the existing adapter; Task 5 does
+  not claim an artifact-backed quality-check implementation.
+- `ArtifactStore.read` remains a compatibility/helper API that can return a
+  bounded in-process frame. The production Polars adapter does not call it;
+  it uses staged files plus the isolated worker.
+- Windows requires a separate Job Object implementation before Polars batch
+  execution can be enabled there; the current implementation fails closed when
+  `RLIMIT_AS` is unavailable.

+ 295 - 0
.superpowers/sdd/task-6-report.md

@@ -0,0 +1,295 @@
+# Task 6 Report — Artifact handoff and governed rule evidence
+
+Date: 2026-07-23
+Branch: `codex/data-rule-execution-m3a-m5`
+
+Lifecycle boundary: Task 6 consumes only plans already marked `published`.
+It does not add, relax, or emulate the Task 7 publication gate.
+
+## Outcome
+
+Task 6 now provides a production-wired path from a signed Runner task to one
+server-attested `rule_runs` record, optional durable violation evidence, and a
+closed artifact-only handoff to the next node.
+
+The implementation includes:
+
+- `PostgresRuleEvidenceWriter` with canonical deployment, component, rule
+  version, plan hash, DataFlow, workflow version, node, and correlation
+  attestation;
+- a server-derived evidence key that makes a node execution idempotent across
+  different signed task tokens and Runner retries;
+- reliable `running` to `success`, `failed`, `unknown`, or `cancelled`
+  finalization with row counts, timings, commit outcome, and bounded public
+  result;
+- a signed, exact deployment/environment task identity plus a durable lease
+  owner, heartbeat, and expiry boundary; an expired run is finalized
+  `unknown` and is never blindly re-executed;
+- same-JTI response replay from the durable ledger, including recovery from
+  terminal rule evidence when execution committed but the HTTP/ledger response
+  was lost;
+- commit-acknowledgement rechecks for both run and sample finalization;
+- a maximum 100-row violation sample, redacted before it leaves the isolated
+  Polars worker and redacted again at the evidence boundary;
+- a `pending` to verified MinIO upload to `ready` sample state machine with
+  digest, schema hash, reference, expiry, and bounded TTL cleanup;
+- no raw sample values in Runner responses or application log calls;
+- correlation-scoped upstream output resolution that accepts only current,
+  ready, unexpired, storage-attested artifacts;
+- an opaque `dataops-staging://<uuid>` SQL receipt for same-source pushdown
+  handoff, bound to producer run, deployment, correlation, dataset binding,
+  binding hash, relation digest, commit state, and expiry;
+- bracket-safe Kestra single-predecessor `input_artifact` expressions;
+  governed rule fan-in is rejected until an explicit merge component exists;
+- a top-level Runner `output_artifact` field matching the Kestra expression
+  contract while retaining the full bounded result object.
+
+## Forward-only schema change
+
+Migrations `20260723_160`, `20260723_170`, `20260723_180`, and
+`20260723_190` extend the existing evidence tables without changing historical
+migrations 110, 140, or 150. The second review did not rewrite already-applied
+migrations 160 or 170, and the third review leaves migration 180 unchanged.
+Each additional schema revision is isolated in its own forward-only migration.
+
+`rule_runs` gains:
+
+- nullable legacy-compatible `evidence_key` with a unique constraint;
+- `commit_outcome`;
+- bounded `public_result`;
+- `failure_code` and `updated_at`.
+
+`rule_violation_samples` gains:
+
+- artifact and schema digests;
+- `legacy`, `pending`, `ready`, and `failed` handoff states;
+- failure and update fields;
+- one sample per run;
+- a strict 100-row ceiling and retention index.
+
+Downgrade is deliberately rejected because deleting execution evidence would
+break audit and replay guarantees.
+
+Migration 160 explicitly preflights duplicate legacy `rule_run_id` samples and
+legacy samples over 100 rows before adding constraints. Migration 170 adds
+leases, exact task deployment identity, bounded replay fields, evidence
+digests, and SQL staging receipts. Migration 180 adds the Runner ledger lease,
+bounded cleanup claims, retained sample schema fields, and the
+`unknown`/`expired` reconciliation states. Migration 190 adds expiring cleanup
+claim leases for violation samples and SQL staging receipts, allowing a crashed
+cleanup worker to be taken over through compare-and-set after lease expiry.
+
+The local Docker PostgreSQL was upgraded through the formal
+`20260723_150 -> head` Alembic path. Two isolated real PostgreSQL tests prove
+that duplicate and oversized legacy samples abort with a clear diagnostic and
+leave Alembic at revision 150.
+
+## RED / GREEN evidence
+
+The initial fail-first slice produced five expected failures:
+
+- four executor evidence tests failed because `RulePlanExecutor` did not accept
+  an evidence writer;
+- the Kestra artifact-only test failed because downstream parameters contained
+  only workflow inputs and no upstream output reference.
+
+A second fail-first assertion proved that the Polars adapter did not expose a
+bounded violation sample before the worker implementation was added.
+
+First review verification:
+
+- focused Runner/rule/Kestra/schema suite: `129 passed`;
+- real PostgreSQL/MySQL/MinIO evidence acceptance: `1 passed`;
+- real SQL adapter acceptance: `4 passed`;
+- real malformed `150 -> head` migration acceptance: `2 passed`;
+- full suite: `594 passed, 28 skipped, 59 subtests passed`;
+- Ruff across every changed Python file: `All checks passed!`;
+- `git diff --check`: passed;
+- Docker Compose configuration validation: passed;
+- historical migration 140/150 diff check: empty.
+
+## Second-review reconciliation hardening
+
+The second review closes the remaining crash and identity gaps:
+
+- the Runner task ledger has a bounded lease. A crash after claiming a token
+  but before creating `rule_runs` atomically converges to `unknown`;
+- an existing `rule_runs` row with an expired evidence lease also converges to
+  `unknown`, finalizes the exact ledger JTI, and cannot stay at HTTP 202
+  permanently;
+- expired task tokens are replay-only for a bounded window. Only the exact
+  stored JTI, node digest, deployment/environment/workflow binding, and
+  response digest may return a prior success. Missing, running, out-of-window,
+  or tampered requests are rejected without execution;
+- the scheduling gateway now resolves and persists the canonical
+  `dataflow_deployments.id`. A workflow candidate ID is never used as a rule
+  deployment ID;
+- the real gateway path binds the released governance DataFlow version to the
+  exact workflow version, schedule, and environment before issuing canary
+  tokens;
+- rule adapters run with a periodic lease heartbeat. Heartbeat failure records
+  `unknown` and preserves a known committed adapter outcome in evidence;
+- violation-sample reconciliation claims at most a bounded batch, performs
+  MinIO work outside database transactions, verifies digest/schema/count, and
+  safely releases retryable unknown claims;
+- expired sample deletion is a claim, object deletion, and conditional
+  database deletion sequence, so no object network call occurs while holding
+  a database transaction;
+- SQL staging receipts have equivalent bounded expiry claims and immutable
+  audit retention;
+- all Kestra predecessor references, including non-rule fan-in, use
+  bracket-safe expressions.
+
+Second-review verification:
+
+- targeted rule, Runner, scheduling, Kestra, and schema suite:
+  `52 passed`;
+- task-token and Runner replay boundary suite: `12 passed`;
+- real PostgreSQL/MySQL/MinIO/Gateway/Runner/evidence acceptance:
+  `1 passed`;
+- full suite: `601 passed, 28 skipped, 59 subtests passed`;
+- Ruff across every file changed by the second review:
+  `All checks passed!`;
+- `git diff --check`: passed;
+- migrations 160 and 170 diff against the first-review commit: empty;
+- real local PostgreSQL upgrade: `20260723_170 -> 20260723_180 (head)`;
+- rebuilt local Docker backend and Runner: both healthy;
+- Runner `/health`: `{"status":"ok"}`;
+- Alembic inside the rebuilt backend container:
+  `20260723_180 (head)`.
+
+The full-repository Ruff invocation still reports 430 pre-existing findings
+outside this change set. No changed file contributes a Ruff finding.
+
+## Third-review closed replay and cleanup takeover
+
+The third review makes terminal evidence and cleanup ownership explicit:
+
+- Runner recovery accepts only three exact replay shapes: `running`,
+  `missing`, or a fully closed `terminal` response. A terminal success must
+  carry a matching canonical result digest, evidence digest, status, and commit
+  outcome before the durable ledger may be finalized;
+- a genuinely running execution always returns HTTP 202 and never stores a
+  success response in the ledger. An expired replay-only token pointing to
+  running evidence is rejected with HTTP 401;
+- terminal replay responses returned by the PostgreSQL evidence writer include
+  both the canonical result digest and the persisted evidence digest, so the
+  API does not infer success from an arbitrary dictionary;
+- violation-sample and SQL-receipt cleanup claims now have explicit expiry.
+  Workers claim rows with compare-and-set semantics, cannot steal an active
+  claim, and can take over a claim left behind by a crashed worker after its
+  lease expires;
+- violation sample reconciliation checks object existence through the storage
+  adapter before promotion. A confirmed missing object becomes failed and is
+  eligible for cleanup, while transient storage/network errors retain the
+  original pending or unknown state and release the claim for retry;
+- object-store operations remain outside database transactions, and every
+  success, failure, and retry path clears both the claim owner and expiry.
+
+Third-review verification:
+
+- focused Runner, evidence, token, ledger, schema, and real integration suite:
+  `41 passed`;
+- real PostgreSQL/MySQL/MinIO integration acceptance, including live running
+  replay, closed terminal digest recovery, cleanup crash/takeover, confirmed
+  missing object, and transient storage failure: `1 passed`;
+- full suite: `605 passed, 28 skipped, 59 subtests passed`;
+- Ruff across every file changed by the third review:
+  `All checks passed!`;
+- `git diff --check`: passed;
+- migration 180 diff against the second-review commit: empty;
+- real local PostgreSQL upgrade: `20260723_180 -> 20260723_190 (head)`;
+- rebuilt local Docker backend and Runner: both healthy;
+- Runner `/health`: `{"status":"ok"}`;
+- Alembic inside the rebuilt backend container:
+  `20260723_190 (head)`.
+
+## Fourth-review revision-180 claim compatibility
+
+The fourth review closes the upgrade boundary for cleanup claims created by
+revision-180 workers:
+
+- revision 180 already allowed a cleanup worker to persist a non-null
+  `cleanup_claim`, but it had no claim-expiry column;
+- migration 190 now backfills every such historical sample and SQL-receipt
+  claim with `cleanup_claim_expires_at = CURRENT_TIMESTAMP` immediately after
+  adding the column;
+- rows without a claim remain unclaimed with a null expiry;
+- the deployment contract requires revision-180 cleanup workers to be stopped
+  during the schema upgrade. This makes immediate expiry safe and prevents an
+  old worker from racing a revision-190 takeover;
+- after upgrade, the normal owner-and-expiry compare-and-set contract can
+  replace the old claim, so a crash before migration cannot permanently lock
+  either evidence table.
+
+The real migration acceptance creates an isolated PostgreSQL database, upgrades
+it to revision 180, inserts non-null legacy claims into both
+`rule_violation_samples` and `rule_sql_staging_receipts`, and then upgrades to
+190. It verifies both expiry values are non-null and already reclaimable, then
+successfully replaces both old owners through the production-compatible CAS
+predicate.
+
+Fourth-review verification:
+
+- focused schema, real migration, Runner, evidence, ledger, and token suite:
+  `42 passed`;
+- isolated real PostgreSQL `180 -> 190` legacy-claim upgrade:
+  passed for both evidence tables;
+- full suite: `606 passed, 28 skipped, 59 subtests passed`;
+- changed-file Ruff and `git diff --check`: passed;
+- migration 180 remains byte-for-byte unchanged from the second-review
+  baseline.
+
+## Real PostgreSQL and MinIO acceptance
+
+The production-path integration uses:
+
+- source PostgreSQL and source MySQL;
+- platform PostgreSQL at migration head;
+- real MinIO;
+- the isolated Polars worker;
+- `PostgresRulePlanRepository`;
+- `PostgresArtifactResolver`;
+- `PostgresRuleEvidenceWriter`;
+- `RulePlanExecutor`;
+- signed Runner HTTP tokens and the durable task ledger.
+
+It proves:
+
+1. the first node reads the PostgreSQL and MySQL artifacts, executes
+   normalize/join/assert/deduplicate, publishes an output, and records one
+   successful run;
+2. one violating row is stored as an all-field-redacted, expiring Parquet
+   sample with ready state and exact digest;
+3. a second published rule node consumes the first node's exact
+   `output_artifact` reference and publishes a distinct output;
+4. the same correlation contains exactly two successful run records;
+5. a different signed token for the same node replays the existing run and
+   sample instead of executing again;
+6. same-JTI retry returns the exact stored response without invoking the
+   adapter again, and a lost HTTP/ledger response is rebuilt from terminal
+   rule evidence;
+7. the sample-ready/run-finalize crash window is retried safely without
+   changing evidence or leaking raw values;
+8. the lease owner is enforced, heartbeat extends only the owned run, and an
+   expired lease becomes `unknown` instead of being reclaimed;
+9. a real opaque SQL receipt is unavailable while pending, becomes executable
+   only after a committed producer finish, and rejects forged, cross-
+   correlation, and failed-producer references;
+10. same-status/different-content finish retries are rejected by the exact
+    evidence digest;
+11. real MinIO reconciliation prevents active-claim theft, takes over an
+    expired claim, marks confirmed-missing evidence failed, preserves transient
+    failures for retry, and removes an unreferenced old object;
+12. cleanup claim takeover also applies to opaque SQL staging receipts;
+13. a failed adapter records `failed/not_committed`;
+14. an uncertain write records `unknown/unknown`;
+15. all test-owned PostgreSQL rows and MinIO objects are removed after
+   acceptance.
+
+## Residual boundary
+
+Task 6 deliberately does not decide whether a compiled plan may become
+published. Task 7 must require trusted generation, test, and preflight evidence
+before changing plan lifecycle state. The evidence added here is the immutable
+input to that gate, not a bypass around it.

+ 260 - 0
.superpowers/sdd/task-7-report.md

@@ -0,0 +1,260 @@
+# Task 7 Report — Trusted rule generation and publication gates
+
+Date: 2026-07-24
+Branch: `codex/data-rule-execution-m3a-m5`
+
+## Outcome
+
+Task 7 establishes a fail-closed path from an AI-authored natural-language
+candidate to an immutable published RuleVersion. Neither caller-supplied
+compile evidence nor caller-supplied test evidence can advance lifecycle
+state.
+
+The delivered chain is:
+
+1. `/interpret` resolves distinct server-owned input/output schema snapshots,
+   a required input sample, and an optional golden output before invoking the
+   authoring model.
+2. A ready rule candidate is persisted with its actor, source hash, candidate
+   hash, RuleSpec hash, model hash, prompt hash, context hash, and all bounded
+   repair attempts.
+3. The server issues a short-lived HMAC-SHA256 generation receipt over that
+   closed claim set.
+4. RuleVersion creation locks the generation row, verifies every claim against
+   canonical database state, consumes the receipt exactly once, and creates a
+   `draft` RuleVersion plus an immutable validation profile.
+5. Logical validation compiles a closed, validation-only Polars plan against
+   the separately pinned input and output schema snapshots.
+6. Logical testing executes that exact plan and any golden comparison in the
+   same isolated Polars worker against pinned MinIO artifacts. Digest, schema,
+   resource, row, expiry, and ownership checks are enforced by
+   `ArtifactStore` and the bounded worker.
+7. Successful evidence advances RuleVersion `draft -> validated` and logical
+   plan `compiled -> tested`.
+8. Publication requires exact successful compile and test evidence and advances
+   both objects to `published`.
+9. Deployment-bound physical plans independently pass
+   `compiled -> tested -> published`. They must reference a published
+   RuleVersion and its successful logical test evidence.
+
+The public catalog returns published rules only. DataFlow release continues to
+consume published plans only, so Task 7 does not weaken the Task 4–6 execution
+boundary.
+
+## Receipt and concurrency boundary
+
+`GenerationReceiptSigner` signs a compact, closed JSON claim set containing:
+
+- receipt version;
+- generation run ID;
+- actor UID;
+- source text hash;
+- candidate hash;
+- RuleSpec hash;
+- model hash;
+- prompt hash;
+- validation context hash;
+- issued-at and expiry timestamps.
+
+Receipts use the dedicated required `RULE_GENERATION_RECEIPT_SECRET` and never
+fall back to the Flask session secret. Issued-at, expiry, maximum lifetime, and
+clock skew are checked cryptographically and against current database time
+during consumption.
+
+Draft creation performs `SELECT ... FOR UPDATE` on the generation record and a
+compare-and-set consumption update. A new consumption is rejected if it is expired,
+tampered, belongs to another actor, has a different source/RuleSpec/candidate
+or validation context, has already been consumed, or is linked to another
+version.
+
+An exact same-actor receipt replay after an unknown commit outcome returns the
+already-linked canonical draft even after the short-lived receipt expires.
+This replay path still requires the same signed receipt hash and every canonical
+claim to match; expiry is bypassed only after the consumed row is proven exact.
+New consumption uses PostgreSQL `clock_timestamp()` rather than the
+transaction-start timestamp, so a long-running transaction cannot extend a
+receipt's usable lifetime.
+
+Publication locks the rule/plan/profile/schema or
+plan/component/rule/binding/schema rows in one transaction and re-reads the
+current compiler, RuleSpec, plan, schema, binding, data source, object,
+dialect, evidence, and audit chain. Same-actor retries after an unknown commit
+outcome return the canonical published result; a different actor cannot use
+that replay path. Real PostgreSQL concurrency tests use independent threads,
+connections, and sessions: same-actor contenders return one canonical result
+and one audit, while a different actor is rejected after the winning commit.
+
+## AI repair boundary
+
+The authoring agent now has a deterministic maximum of two repair attempts.
+Each attempt persists:
+
+- attempt number and outcome;
+- candidate hash;
+- deterministic error code;
+- model, prompt, and context hashes.
+
+The prompt hash is calculated from the actual full prompt sent for that
+attempt, including the output JSON schema and accumulated repair messages.
+The final generation receipt therefore binds the final repair prompt, not the
+initial prompt.
+
+Only malformed JSON and closed candidate-contract errors are repairable.
+Ambiguity and low confidence return clarification requirements immediately.
+Permission failures, destructive scope, lifecycle failures, and execution
+failures are never sent into the automatic repair loop.
+
+## Logical and physical evidence
+
+Logical compile evidence binds the RuleVersion, compiler version, exact plan
+hash, schema hashes, capabilities, and actor. Logical test evidence additionally
+binds the exact trusted run ID and server-generated attestation:
+
+- input artifact digest;
+- output artifact digest;
+- output schema hash;
+- violation digest;
+- bounded row and violation counts.
+
+All four evidence writers use `INSERT ... RETURNING`. On a uniqueness conflict
+they load the canonical database row, compare the complete security-relevant
+content, and return the real existing evidence ID; divergent replay fails
+closed.
+
+The logical runner supports `assert` with both `reject` and `quarantine`.
+Quarantined rows are removed from the primary output, counted separately as
+`rows_quarantined`, and are not misreported as rejected. The compiler version
+was advanced to `dataops-polars-1.43.0` for this result-contract change.
+
+Physical SQL preflight validates the exact SQLGlot-bound plan and executes only
+`EXPLAIN <compiled INSERT ... SELECT ...>` through the governed data-source
+manager. It never uses `ANALYZE` and never executes the DML. The resulting
+evidence contains dialect, statement digest, and EXPLAIN digest. Real
+PostgreSQL and MySQL tests assert that destination row counts remain zero.
+
+Physical Polars preflight stages the latest trusted, ready, unexpired input
+artifact and executes the exact bound plan in the isolated worker. Both
+physical backends are rejected on current schema, binding, dialect, plan-hash,
+RuleVersion, or logical-evidence drift.
+
+Logical dry-run supports a distinct output schema and optional golden Parquet
+result. The server stages both artifacts and passes their local paths and
+digests to one spawned worker governed by the plan's single RLIMIT/RSS/timeout
+budget. That worker verifies the staged golden digest and compares exact
+schema, row count, row order, null positions, and field values. The Flask
+process does not materialize either Parquet result. Concurrent large-golden
+tests fail closed inside the same resource boundary. The real
+cross-schema acceptance reads `name/mobile`, derives `name_copy`, removes one
+quarantined row, and compares the output to the pinned golden artifact.
+
+## Forward-only migration
+
+Migration `20260723_200` is forward-only and adds:
+
+- corrected RuleVersion and execution-plan lifecycle constraints;
+- receipt hash, consumption timestamp, validation context, model hash, and
+  prompt hash on generation runs;
+- one-time receipt/version uniqueness constraints;
+- persisted generation attempts;
+- immutable validation profiles;
+- logical plans and logical compile/test evidence;
+- physical compile/test evidence binding fields;
+- validation failures and publication audits;
+- the published-rule catalog index.
+
+The migration refuses to run when revision 190 contains any RuleVersion or
+execution plan, including draft and validated rows, because no pre-Task-7 row
+can possess the required generation, compile, test, and publication provenance.
+Operators must export and remove all legacy rows, apply migration 200, and then
+rebuild each rule through the Task 7 chain. Physical evidence carries an explicit
+`legacy_untrusted` marker, and publication/Runner/DataFlow gates require it to
+be false. Downgrade is deliberately rejected because deleting publication and
+generation evidence would break audit and replay guarantees.
+
+## API surface
+
+The following server-owned operations are available:
+
+- `POST /api/rules/interpret`;
+- `POST /api/rules/rule-versions`;
+- `POST /api/rules/rule-versions/<id>/validate`;
+- `POST /api/rules/rule-versions/<id>/test`;
+- `POST /api/rules/rule-versions/<id>/publish`;
+- `GET /api/rules/rule-versions/<id>/evidence`;
+- `GET /api/rules/catalog`;
+- `POST /api/rules/execution-plans/<id>/validate`;
+- `POST /api/rules/execution-plans/<id>/test`;
+- `POST /api/rules/execution-plans/<id>/publish`.
+
+Request bodies and catalog query parameters use closed shapes. Compile/test
+endpoints do not accept caller evidence. Editor permissions cover
+validation/testing while publication keeps the existing administrative
+permission boundary. The documented canonical catalog path is implemented;
+the prior nested path remains a compatibility alias.
+
+`/interpret` preflights the dedicated receipt signer before resolving context
+or invoking the model, so missing configuration cannot incur model cost.
+Local and production environment templates and deployment runbooks document
+generation and coordinated rotation of `RULE_GENERATION_RECEIPT_SECRET`
+without containing a real secret.
+
+DataFlow release no longer trusts RuleVersion status alone. Its production
+asset loader requires the exact published logical plan, successful compile
+evidence, successful test evidence, matching plan/schema/capability hashes,
+and the matching publication audit. Revoked or drifted logical evidence makes
+the referenced rule unavailable and the release fails closed.
+
+## Acceptance evidence
+
+Fail-first evidence included:
+
+- publication rejected without exact compile and test evidence;
+- invalid, expired, replayed, actor-mismatched, source-mismatched, and
+  RuleSpec-mismatched receipts;
+- concurrent receipt consumption;
+- caller-forged test evidence;
+- schema, binding, dialect, and plan drift;
+- bounded repair exhaustion and no-repair ambiguity;
+- initial quarantine compilation failure before support was added.
+
+Final verification:
+
+- focused second-round trust-boundary suite: `39 passed`;
+- Golden negative tests use real file SHA-256 and equal-row content drift to exercise worker-side row comparison;
+- focused data-rule/API/migration suite from the initial delivery:
+  `183 passed`;
+- Runner plus real PostgreSQL/MySQL/Polars regression: `126 passed`;
+- real PostgreSQL + MinIO cross-schema receipt/logical lifecycle:
+  `1 passed`;
+- real `PhysicalPlanPublicationService` PostgreSQL/MySQL lifecycle, drift,
+  evidence replay, and PostgreSQL concurrency: `2 passed`;
+- full repository suite:
+  `632 passed, 29 skipped, 59 subtests passed`;
+- changed-file Ruff: `All checks passed!`;
+- `git diff --check`: passed;
+- empty local PostgreSQL full Alembic rebuild:
+  base through `20260723_200 (head)`;
+- real revision-190 draft/validated/published upgrade guards: `3 passed`.
+- rebuilt local Docker backend: container reported `healthy`, and
+  `GET /api/system/health` returned HTTP/application code `200` with database
+  and Neo4j healthy.
+
+Skipped tests are environment-gated integration suites. The required real
+PostgreSQL, MySQL, and MinIO acceptance tests above were run explicitly against
+the local Docker services and passed.
+
+## Residual scope
+
+Task 7 records publication preflight run IDs and exact evidence in dedicated
+logical/physical evidence tables. It does not fabricate production `rule_runs`
+for logical validation because those rows require a real deployment and
+component binding. Production execution evidence remains owned by the Task 6
+Runner path after a physical plan is published.
+
+Task 7 now supports separate input/output snapshots, cross-schema
+transformations, deployable secret templates, and a documented rotation
+procedure. The remaining scope is production operational rollout:
+multi-replica load and failure testing, monitoring/alert thresholds, and
+production canary/rollback evidence. Those
+operational gates are outside this local Docker acceptance and must be closed
+before a production environment is declared ready.

+ 419 - 0
.superpowers/sdd/task-8-report.md

@@ -0,0 +1,419 @@
+# Task 8 Report — Standard / Data Flow product convergence
+
+Date: 2026-07-24
+Branch: `codex/data-rule-execution-m3a-m5`
+
+## Outcome
+
+Task 8 removes the user-facing parallel rule implementations from Data
+Standard and Data Flow authoring.
+
+The governed path is now:
+
+1. describe a constraint in natural language against fixed input/output
+   schema context;
+2. review AI assumptions, ambiguities, model/prompt provenance and the signed
+   generation receipt;
+3. create an immutable draft RuleVersion;
+4. run server-owned logical compilation and isolated sample testing;
+5. publish only after trusted evidence succeeds;
+6. select a published fixed RuleVersion or StandardVersion from one catalog;
+7. assemble those fixed version IDs into ordered production-line stations;
+8. hand the resulting DataFlowSpec to the release/Data Factory boundary.
+
+Generated source code is never editable in the new path. Legacy code and
+free-text flow rules remain visible only in explicit migration sections and
+are excluded from new execution semantics.
+
+## Trusted catalog and evidence API
+
+The control plane now exposes:
+
+- `GET /api/rules/catalog`
+  - closed `query`, `asset_type`, `limit`, and `offset` parameters;
+  - rules and standards in one paginated contract;
+  - human-readable name, stable asset UID, fixed version, owner, status,
+    schema binding/scope, impact count, compiler backend, and latest
+    compile/test evidence;
+  - only published rules with an exact published logical plan, matching
+    compile evidence, matching test evidence, and a publication audit;
+  - only published standards whose fixed rule bindings are still published.
+- `GET /api/rules/catalog/assets/<rule|standard>/<version_id>/evidence`
+  - safe generation, logical compilation, dry-run, publication and physical
+    plan stages;
+  - IDs, hashes, lifecycle states, test kinds, and run IDs only;
+  - no source text, generated candidate body, artifacts, samples, or
+    unbounded evidence documents.
+
+Compatibility aliases remain available at
+`/api/rules/catalog/rule-versions` and
+`/api/rules/rule-versions/<version_id>/evidence`. Global API policy keeps all
+GET paths behind `rules:read`.
+
+## AI authoring lifecycle
+
+`RuleAuthoringPanel` now:
+
+- blocks interpretation until input and output context are present;
+- displays clarification requirements rather than guessing;
+- shows assumptions, unresolved ambiguities, confidence, model/provider,
+  prompt revision, repair count, and generation receipt status;
+- creates a draft with the signed generation receipt;
+- exposes explicit compile, isolated test and publish actions;
+- enforces `rules:edit` and `rules:publish` UI gates;
+- displays the trusted evidence timeline;
+- never renders or edits generated code or a serialized candidate body.
+
+## Data Standard convergence
+
+Data Standard editing now links a published RuleVersion through
+`RuleCatalogPicker`. New saves contain the fixed `rule_version_id` and bounded
+`migration_metadata`; they do not require or write an operation-code field.
+
+Existing operation code is shown in a read-only migration area with clear
+`unmigrated` / `linked` state and a path back to natural-language
+re-authoring. It cannot become execution semantics merely by saving the
+legacy record.
+
+## Data Flow production-line assembly
+
+Data Flow no longer edits free-text rules or embeds rule definitions.
+`ProductionLineAssembler` models the flow as ordered factory stations:
+
+- `standard.enforce` fixes a `standard_version_id`;
+- `rule.apply` and `quality.check` fix a `rule_version_id`;
+- input/output selections are shown as dataset edges;
+- schema-compatibility and latest-evidence summaries are shown per station;
+- missing UID, dataset edges, fixed assets, compatibility, or evidence become
+  explicit release-readiness blockers;
+- reorder, remove and keyboard-accessible catalog selection are supported.
+
+The new saved `script_requirement` envelope contains only:
+
+- `dataflow_spec`;
+- `dataset_edges`;
+- bounded `migration_metadata`.
+
+It contains no inline rule definition or generated source. Existing legacy
+requirements remain read-only and are not copied into that envelope.
+
+## UX and accessibility
+
+The implementation reuses Vue 2, Vuetify, Material Design Icons, and the
+existing request client. No dependency or design system was added.
+
+The interface uses a low-motion, medium/high-density governance-workbench
+style. Catalog loading, empty and error states are explicit. Catalog items
+support focus, arrow navigation, Enter/Space selection, listbox semantics,
+ARIA labels, and skeleton loading. Responsive layout collapses the Data Flow
+dataset grid on narrow screens.
+
+## Acceptance evidence
+
+Fail-first:
+
+- the initial frontend contract run produced `5 failed, 3 passed` because the
+  three governed components and convergence behavior did not exist.
+
+Final:
+
+- Task 8 API/repository/frontend, Saga and legacy-cutover contracts:
+  `64 passed`;
+- full repository suite:
+  `673 passed, 33 skipped, 59 subtests passed`;
+- `git diff --check`: passed;
+- frontend production build: completed with `0 errors`;
+- build retained 20 pre-existing `no-console` warnings plus existing CSS
+  ordering and bundle-size warnings;
+- unified catalog SQL executed against the migrated local PostgreSQL service
+  and returned a valid empty page;
+- rebuilt local Docker backend and frontend images successfully;
+- backend, frontend, PostgreSQL, Neo4j, MinIO and Runner containers reported
+  healthy;
+- `/api/system/health` returned application code `200`;
+- unauthenticated `/api/rules/catalog` returned HTTP `401`.
+
+Browser automation opened the rebuilt deployment and verified the local login
+surface. The documented local sample administrator password did not match the
+persisted administrator in this long-lived Docker volume, so authenticated
+Standard/Data Flow navigation was not forced by overwriting or resetting
+credentials. The build, component contracts, authenticated API tests, real
+PostgreSQL query, and container health checks provide the Task 8 acceptance
+evidence; an authenticated visual walkthrough can be repeated during the
+phase-level M4 acceptance with the environment owner’s current local
+credential.
+
+## Review closeout
+
+The post-implementation review found eight convergence gaps. All eight are
+closed:
+
+1. idempotency is emitted only for `rule.apply`; `standard.enforce` and
+   `quality.check` now match the closed DataFlow contract;
+2. catalog compatibility is never inferred from an arbitrary non-empty
+   object; absent production-line context remains explicitly `unknown`;
+3. `POST /api/interface/data/standard/code` is closed with HTTP 410 and
+   read-only-migration semantics;
+4. Standard add/update rejects executable code and server-attests any
+   `rule_version_id` against the complete trusted publication evidence chain;
+5. DataFlow drafts receive a server-owned UUIDv7 before assembly and saving
+   fails closed if no identity can be obtained;
+6. governed DataFlow create/update validates a closed envelope, verifies all
+   referenced versions are published, preserves the immutable identity, and
+   never invokes task-list, generated-script, n8n-workflow, or legacy data
+   product side effects;
+7. Standard authoring no longer fabricates a client-side `validated` state;
+   new Standard clauses must bind a server-published RuleVersion;
+8. generation evidence state `ready` is rendered as successful and async
+   station values are reloaded by the existing deep watcher.
+
+The cutover deliberately preserves legacy non-governed records and
+read-only migration displays, while preventing those fields from becoming
+new governed execution semantics.
+
+## Security review closeout
+
+The second review strengthened the boundary from server-issued identifiers
+to database-enforced, single-use draft reservations:
+
+- migration `20260723_210` adds a unique DataFlow UID, actor foreign key,
+  unique nonce digest, expiration and consumption ledger;
+- the draft endpoint persists a short-lived reservation and returns a closed
+  receipt; governed creation atomically consumes it using one conditional
+  `UPDATE ... RETURNING`;
+- UID, actor, nonce digest, expiry and unused state must all match, so
+  self-created UUIDs, replay, cross-actor use, expiration and concurrent
+  duplicate consumption fail closed;
+- an existing governed DataFlow requires the complete governed envelope on
+  every update, keeps the existing UID and cannot be downgraded through
+  `script_type`, script path, task, code or workflow fields;
+- any partial governed signal is classified as governed and rejected before
+  legacy task/script/n8n side effects can run.
+
+Data Standard natural-language authoring now deterministically produces a
+Rule candidate. The prompt states that rule explicitly, the authoring agent
+repairs a model-produced Standard candidate, and the API independently checks
+the post-validation candidate before recording or signing it. New legacy
+Standard records require an exactly attested published RuleVersion; only an
+existing legacy record may be updated without one for read-only migration.
+
+Catalog compatibility is now evaluated server-side against the production
+line's current input and output schema references. It returns
+`compatible`, `incompatible`, or `unknown` with a reason and evidence.
+The frontend supplies this context, blocks unknown/incompatible stations,
+hydrates an already-fixed version through an exact-version endpoint even
+when it is outside the first catalog page, and uses a monotonically
+increasing request sequence so stale searches cannot overwrite newer
+context.
+
+Real PostgreSQL acceptance confirmed:
+
+- Alembic upgraded the persisted Docker database from revision 200 to
+  `20260723_210 (head)`;
+- the reservation table exists and its actor foreign key rejects an unknown
+  actor;
+- two independent concurrent database sessions consuming one receipt produce
+  exactly one acceptance and one rejection;
+- replay, cross-actor and expired receipt consumption are rejected;
+- acceptance fixtures are removed after the test.
+
+## Recoverable create and trusted-schema closeout
+
+The third review replaced the one-shot PostgreSQL-consume/Neo4j-create
+boundary with a recoverable create Saga. Historical migration 210 remains
+unchanged; forward-only migration `20260724_220` adds:
+
+- `reserved`, `creating`, `completed`, and `failed` states;
+- an expiring create lease, attempt counter and error code;
+- the Neo4j node ID, bounded result JSON and canonical SHA-256 digest;
+- create-started, failed, completed and updated timestamps.
+
+`begin_dataflow_create` now uses one conditional `UPDATE ... RETURNING` to
+claim a lease. The claim is committed before the graph side effect. Governed
+DataFlow creation installs the idempotent `DataFlow.uid` unique constraint,
+uses `MERGE` only on the server-reserved UID, and verifies the persisted name,
+UID, script type and canonical governed requirement without overwriting an
+existing node. Completion conditionally owns the lease and persists the exact
+response. A retry after response loss returns that stored response; an expired
+lease reclaims the same UID, observes the existing graph node and finalizes it.
+Name/UID or immutable-property conflicts fail closed.
+
+Catalog compatibility no longer trusts matching reference strings alone:
+
+- the current input/output references are resolved through `SchemaResolver`;
+- published rules compare the current snapshot hashes with the hashes frozen
+  into their published logical plan;
+- compile and test evidence IDs must be tied to that same logical plan and
+  hash set;
+- a Standard expands every fixed `standard_rule_bindings` entry and requires
+  the same trusted rule plan/evidence/hash chain for every clause;
+- resolver failure returns `unknown`; same-reference hash drift and any bound
+  Standard rule drift return `incompatible`;
+- reader evidence contains only stable IDs, hashes, snapshot IDs and source
+  revisions.
+
+The Data Flow editor stores the reservation expiry, renews it when less than
+60 seconds remain, and changes to a new server UID only when the server
+returns the explicit `draft_reservation_refresh_required` signal. It retries
+that safe pre-side-effect case once. Network errors and in-progress leases do
+not cause a new UID; a completed response-loss retry is replayed by the server.
+
+Additional acceptance evidence:
+
+- local PostgreSQL upgraded from 210 to `20260724_220 (head)`;
+- two real concurrent PostgreSQL sessions produced one claim and one
+  `dataflow_create_in_progress`; completion replay returned the identical
+  result, expired drafts were rejected, and an expired create lease was
+  reclaimed as attempt 2;
+- real Neo4j installed the `data_flow_uid` unique constraint, two same-UID
+  merges produced one node, and immutable drift was rejected;
+- fault-injection tests cover Neo4j failure after a durable PG claim,
+  PostgreSQL finalize failure after Neo4j success, completed-response replay,
+  conflict closure and lease recovery;
+- real PostgreSQL and Neo4j integration: `2 passed`;
+- rebuilt backend and frontend images report migration 230, healthy database
+  and Neo4j checks, application health code 200, and frontend HTTP 200.
+
+## Durable snapshot and orphan-reconciliation closeout
+
+The fourth review closed the remaining transaction and recovery boundaries:
+
+- catalog endpoints now treat `SchemaResolver` as an explicit read-through
+  persistence boundary: successful context-aware GETs commit newly created
+  snapshots before returning their IDs, and every error path rolls back;
+- real PostgreSQL/API acceptance issued two separate catalog requests,
+  observed one stable snapshot ID, and queried that ID from an independent
+  database session;
+- forward-only migration `20260724_230` adds the canonical create request,
+  server-owned create intent and SHA-256 request digest without modifying
+  migration 220;
+- first claim atomically freezes request, intent and digest; every later
+  claim, completed replay and finalize must match that digest;
+- changing the name or any other canonical payload/intent value while
+  reusing a receipt fails with `dataflow_create_request_conflict` instead of
+  silently returning an older result;
+- governed creation uses the supplied English name or a deterministic
+  UID-derived fallback and never calls non-deterministic translation before
+  computing the request digest.
+
+`DataFlowCreateReconciler` and
+`python -m app.commands.reconcile_dataflow_creates` recover orphaned graph
+nodes without accepting a nonce or inline business payload:
+
+- dry-run is the default and reports bounded safe IDs, state, digest and
+  attempt information without changing state or attempt count;
+- apply mode claims expired `creating` or retryable `failed` rows with
+  `FOR UPDATE SKIP LOCKED` and an expiring lease;
+- canonical request digest, duplicated intent and DataFlow UID are verified
+  before any Neo4j write;
+- the existing UID-only graph `MERGE` either finds the orphan or creates the
+  missing node, verifies immutable properties, and then finalizes PostgreSQL;
+- corrupt intent rows receive a terminal integrity error and do not block
+  valid rows; other failures are isolated per reservation and remain
+  retryable;
+- two real concurrent reconcilers produced exactly one claim;
+- real PostgreSQL/Neo4j acceptance created a graph node, deliberately omitted
+  PostgreSQL finalize, discarded the receipt, expired the lease, and then
+  completed the Saga from stored intent with exactly one graph node;
+- the reconciliation CLI dry-run completed against the local database with
+  an empty, non-mutating report.
+
+Fourth-review acceptance:
+
+- focused repository/API/Saga/cutover contracts: `65 passed`;
+- real PostgreSQL/API/Neo4j integration: `4 passed`;
+- full repository suite:
+  `673 passed, 33 skipped, 59 subtests passed`.
+
+## Fenced reconciliation and strict-intent closeout
+
+The fifth review removed the remaining concurrency and poison-record
+failure modes from orphan reconciliation:
+
+- apply mode is bounded by the requested total but claims only one row at a
+  time, commits that claim, processes it and only then claims the next row;
+  a slow first item therefore cannot leave a batch of later items waiting on
+  leases that expire before their processing begins;
+- after the trusted intent is semantically validated and immediately before
+  the Neo4j side effect, the reconciler conditionally renews the lease using
+  the reservation ID and current lease token. Lost ownership prevents the
+  side effect and is reported as `lease_recovery_required`;
+- reconciliation parsing is isolated per claimed row. Scalar, malformed-shape
+  or oversized request/intent JSON becomes terminal
+  `create_intent_integrity_failed` without aborting the next valid item;
+- semantic intent validation failures become terminal
+  `create_intent_validation_failed`. Both terminal codes are excluded from
+  later list and claim queries, while infrastructure failures remain
+  retryable;
+- failure persistence uses `UPDATE ... RETURNING`; a stale worker that no
+  longer owns the lease cannot report a false successful failure transition;
+- governed tag relationships now use one atomic Neo4j
+  `MERGE (DataFlow)-[:LABEL]->(DataLabel)` path shared by normal creation and
+  reconciliation. Governed tag failures are strict: PostgreSQL is not
+  finalized, the Saga remains retryable, and a later retry completes without
+  duplicate relationships. Legacy tag handling retains its previous
+  best-effort behavior.
+
+Fifth-review acceptance:
+
+- focused Saga/repository/cutover contracts: `40 passed`;
+- real PostgreSQL/API/Neo4j integration: `3 passed`;
+- a full repository run exposed one overly narrow receipt-tamper assertion:
+  modified base64url input can correctly fail either canonical encoding or
+  signature verification. The assertion now accepts both fail-closed reasons;
+  the fresh final run is `681 passed, 33 skipped, 59 subtests passed`;
+- selected Ruff `F`, `I`, and `B` checks passed;
+- the production frontend build completed with zero errors and the same
+  existing 20 console warnings;
+- the backend image was rebuilt from the reviewed source; backend and frontend
+  containers are healthy, Alembic reports `20260724_230 (head)`, application
+  health reports database and Neo4j healthy with code 200, and frontend HTTP
+  returns 200.
+
+## DataFlow name concurrency closeout
+
+The sixth review closed the final governed-create time-of-check/time-of-use
+window around `DataFlow.name_zh`:
+
+- governed graph creation now installs and consumes both
+  `data_flow_uid` and `data_flow_name_zh` uniqueness constraints before its
+  preflight and UID-keyed `MERGE`;
+- constraint installation scans existing graph state. Historical duplicate
+  UIDs or Chinese names make installation fail closed; they are never silently
+  accepted or rewritten;
+- concurrent first-time schema installation retries only Neo4j deadlock
+  victims. Constraint validation/verification/creation failures and concurrent
+  unique-property violations are normalized to `dataflow_uid_conflict`;
+- the existing same-name preflight remains useful diagnostics, while the
+  database uniqueness constraint is authoritative at the concurrent write
+  boundary.
+
+Sixth-review acceptance:
+
+- focused Saga/repository/publication/cutover contracts: `47 passed`;
+- the dedicated real-Neo4j integration suite is `2 passed`: two concurrent
+  creates with different UUIDv7 values and the same `name_zh` produced exactly
+  one success and one `dataflow_uid_conflict`, left one node, and verified both
+  constraints;
+- a simulated historical duplicate made name-constraint installation fail
+  before any graph `MERGE`;
+- full repository suite:
+  `682 passed, 34 skipped, 59 subtests passed`;
+- selected Ruff `F`, `I`, and `B` checks and whitespace validation passed;
+- the production frontend build completed with zero errors and the same
+  existing 20 console warnings;
+- the final backend image was rebuilt from the reviewed source; backend and
+  frontend are healthy, Alembic remains `20260724_230 (head)`, application
+  health reports database and Neo4j healthy with code 200, and frontend HTTP
+  returns 200.
+
+## Residual scope
+
+- Production-line cross-station compatibility remains ultimately authoritative
+  at server-side release resolution; the UI presents current bound schema
+  evidence and blocks on missing evidence rather than claiming runtime success.
+- Data Factory activation, canary, rollback, deployment permissions and
+  environment operations remain Task 9 scope.
+- The repository has no published catalog fixtures in the current persisted
+  local database, so catalog empty state was the live deployment state during
+  this acceptance.

+ 50 - 9
app/__init__.py

@@ -1,5 +1,6 @@
 import logging
 import os
+import uuid
 
 from flask import Flask, jsonify
 from flask_cors import CORS
@@ -11,6 +12,7 @@ from app.config.config import (
     current_env,
     log_llm_env_status,
     log_service_env_status,
+    validate_production_database_identity,
 )
 from app.config.cors import CORS_OPTIONS
 
@@ -29,12 +31,16 @@ def create_app():
     # 配置CORS以解决跨域问题
     CORS(app, **CORS_OPTIONS)
     db.init_app(app)
+    with app.app_context():
+        validate_production_database_identity(app, db.engine)
 
     # 注册蓝图
     from app.api.business_domain import bp as business_domain_bp
+    from app.api.data_development import bp as data_development_bp
     from app.api.data_factory import bp as data_factory_bp
     from app.api.data_flow import bp as data_flow_bp
     from app.api.data_interface import bp as data_interface_bp
+    from app.api.data_rules import bp as data_rules_bp
     from app.api.data_service import bp as data_service_bp
     from app.api.data_source import bp as data_source_bp
     from app.api.graph import bp as graph_bp
@@ -44,9 +50,11 @@ def create_app():
 
     app.register_blueprint(meta_bp, url_prefix="/api/meta")
     app.register_blueprint(data_interface_bp, url_prefix="/api/interface")
+    app.register_blueprint(data_rules_bp, url_prefix="/api/rules")
     app.register_blueprint(graph_bp, url_prefix="/api/graph")
     app.register_blueprint(system_bp, url_prefix="/api/system")
     app.register_blueprint(data_source_bp, url_prefix="/api/datasource")
+    app.register_blueprint(data_development_bp, url_prefix="/api/development/v1")
     app.register_blueprint(data_flow_bp, url_prefix="/api/dataflow")
     app.register_blueprint(business_domain_bp, url_prefix="/api/bd")
     app.register_blueprint(data_factory_bp, url_prefix="/api/datafactory")
@@ -133,7 +141,9 @@ def configure_response_headers(app):
                 response.headers["Access-Control-Allow-Methods"] = methods
                 headers = (
                     "Content-Type, Authorization, X-Requested-With, "
-                    "Accept, Origin, Cache-Control, X-File-Name"
+                    "Accept, Origin, Cache-Control, X-File-Name, If-Match, "
+                    "X-Agent-Credential, X-Edge-Enrollment, X-Edge-Credential, "
+                    "X-Edge-Certificate-SHA256"
                 )
                 response.headers["Access-Control-Allow-Headers"] = headers
                 response.headers["Access-Control-Max-Age"] = "86400"
@@ -154,7 +164,9 @@ def configure_response_headers(app):
                 response.headers["Access-Control-Allow-Methods"] = methods
             if "Access-Control-Allow-Headers" not in response.headers:
                 headers = (
-                    "Content-Type, Authorization, X-Requested-With, Accept, Origin"
+                    "Content-Type, Authorization, X-Requested-With, Accept, Origin, "
+                    "If-Match, X-Agent-Credential, X-Edge-Enrollment, "
+                    "X-Edge-Credential, X-Edge-Certificate-SHA256"
                 )
                 response.headers["Access-Control-Allow-Headers"] = headers
 
@@ -165,6 +177,19 @@ def configure_response_headers(app):
                 response.headers["X-Frame-Options"] = "DENY"
             if "X-XSS-Protection" not in response.headers:
                 response.headers["X-XSS-Protection"] = "1; mode=block"
+            if "Referrer-Policy" not in response.headers:
+                response.headers["Referrer-Policy"] = "no-referrer"
+            if "Permissions-Policy" not in response.headers:
+                response.headers["Permissions-Policy"] = (
+                    "camera=(), microphone=(), geolocation=()"
+                )
+            if (
+                request.path.startswith("/api/system/auth")
+                or request.path.startswith(
+                    "/api/system/governance-audit"
+                )
+            ):
+                response.headers["Cache-Control"] = "no-store"
 
         if request.path.startswith("/api/") and request.path != "/api/system/health":
             app.logger.info(
@@ -237,14 +262,20 @@ def configure_error_handlers(app):
     @app.errorhandler(Exception)
     def handle_exception(e):
         """全局异常处理器,捕获所有未处理的异常"""
-        # 记录详细的错误信息
-        app.logger.error(f"未处理的异常: {str(e)}", exc_info=True)
-
-        # 返回标准化的错误响应
+        from app.core.data_source.redaction import sanitize_exception
+
+        correlation_id = str(uuid.uuid4())
+        app.logger.error(
+            "未处理的异常 correlation_id=%s type=%s detail=%s",
+            correlation_id,
+            type(e).__name__,
+            sanitize_exception(e, limit=300),
+        )
         error_response = {
             "success": False,
-            "message": f"服务器内部错误: {str(e)}",
+            "message": "服务器内部错误",
             "data": None,
+            "correlation_id": correlation_id,
         }
 
         return jsonify(error_response), 500
@@ -260,7 +291,17 @@ def configure_error_handlers(app):
     @app.errorhandler(500)
     def handle_internal_error(e):
         """处理500错误"""
-        app.logger.error(f"500错误: {str(e)}", exc_info=True)
+        correlation_id = str(uuid.uuid4())
+        app.logger.error(
+            "500错误 correlation_id=%s type=%s",
+            correlation_id,
+            type(e).__name__,
+        )
         return jsonify(
-            {"success": False, "message": "服务器内部错误", "data": None}
+            {
+                "success": False,
+                "message": "服务器内部错误",
+                "data": None,
+                "correlation_id": correlation_id,
+            }
         ), 500

+ 7 - 0
app/api/data_development/__init__.py

@@ -0,0 +1,7 @@
+from flask import Blueprint
+
+
+bp = Blueprint("data_development", __name__)
+
+from app.api.data_development import routes  # noqa: E402, F401
+from app.api.data_development import semantic_routes  # noqa: E402, F401

+ 2579 - 0
app/api/data_development/routes.py

@@ -0,0 +1,2579 @@
+"""HTTP orchestration boundary for data-research ingestion jobs."""
+
+from __future__ import annotations
+
+import io
+import logging
+import uuid
+
+from flask import current_app, g, jsonify, request, send_file
+
+from app import db
+from app.api.data_development import bp
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.errors import DataResearchError
+from app.models.result import failed, success
+
+logger = logging.getLogger(__name__)
+
+
+def get_ingestion_service():
+    from app.core.data_research.ingestion import IngestionService
+    from app.core.data_research.repository import SqlAlchemyIngestionJobRepository
+
+    return IngestionService(
+        SqlAlchemyIngestionJobRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_database_source_registration_service():
+    from app.core.data_research.repository import (
+        SqlAlchemyIngestionSourceRepository,
+    )
+    from app.core.data_research.sources import (
+        DatabaseSourceRegistrationService,
+    )
+    from app.core.data_source.runtime import get_data_source_manager
+
+    manager = get_data_source_manager()
+    return DatabaseSourceRegistrationService(
+        SqlAlchemyIngestionSourceRepository(db.session),
+        definition_resolver=manager.definitions.get,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_catalog_snapshot_repository():
+    from app.core.data_research.repository import (
+        SqlAlchemyCatalogSnapshotRepository,
+    )
+
+    return SqlAlchemyCatalogSnapshotRepository(db.session)
+
+
+def get_catalog_ingestion_executor():
+    from app.core.data_research.catalog.execution import (
+        CatalogIngestionExecutor,
+    )
+    from app.core.data_research.catalog.mysql import MySqlCatalogCollector
+    from app.core.data_research.catalog.postgresql import (
+        PostgreSqlCatalogCollector,
+    )
+    from app.core.data_research.catalog.service import CatalogCollectionService
+    from app.core.data_research.errors import IngestionSourceInvalid
+    from app.core.data_source.runtime import get_data_source_manager
+    from app.core.meta_data.active_metadata import ActiveMetadataService
+    from app.core.meta_data.active_metadata_repository import (
+        SqlAlchemyActiveMetadataRepository,
+    )
+
+    manager = get_data_source_manager()
+
+    def collector_resolver(database_type):
+        if database_type == "postgresql":
+            return PostgreSqlCatalogCollector()
+        if database_type == "mysql":
+            return MySqlCatalogCollector()
+        raise IngestionSourceInvalid(
+            f"database type {database_type} is not supported"
+        )
+
+    collector = CatalogCollectionService(
+        manager,
+        definition_resolver=manager.definitions.get,
+        collector_resolver=collector_resolver,
+    )
+    active_metadata = ActiveMetadataService(
+        SqlAlchemyActiveMetadataRepository(db.session)
+    )
+
+    def project_active_metadata(job, snapshot_record):
+        active_metadata.execute_source_plans(
+            snapshot_record.source_uid,
+            snapshot_record.snapshot,
+            batch_key=f"catalog:{snapshot_record.uid}",
+            actor_uid=job.actor_uid,
+            cursor_after={
+                "catalog_snapshot_uid": snapshot_record.uid,
+                "content_hash": snapshot_record.content_hash,
+            },
+        )
+
+    return CatalogIngestionExecutor(
+        get_ingestion_service(),
+        collector,
+        get_catalog_snapshot_repository(),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+        on_snapshot=project_active_metadata,
+    )
+
+
+def get_data_element_service():
+    from app.core.data_research.data_elements import DataElementService
+    from app.core.data_research.repository import SqlAlchemyDataElementRepository
+    from app.core.events.outbox import enqueue_outbox
+
+    return DataElementService(
+        SqlAlchemyDataElementRepository(db.session),
+        outbox_enqueue=lambda **event: enqueue_outbox(db.session, **event),
+    )
+
+
+def get_device_asset_service():
+    from app.core.data_research.device_asset_repository import (
+        SqlAlchemyDeviceAssetRepository,
+    )
+    from app.core.data_research.device_assets import DeviceAssetService
+
+    return DeviceAssetService(
+        SqlAlchemyDeviceAssetRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _device_ontology_authorizer(ontology_repository):
+    from app.core.data_research.device_semantics import (
+        DeviceOntologyPublicationAuthorizer,
+    )
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+    return DeviceOntologyPublicationAuthorizer(
+        ontology_repository,
+        responsibility_lookup=responsibilities.get,
+    )
+
+
+def get_device_semantic_service():
+    from app.core.data_research.device_semantics import DeviceSemanticService
+    from app.core.data_research.ontology.repository import (
+        SqlAlchemyOntologyRepository,
+    )
+
+    return DeviceSemanticService(
+        SqlAlchemyOntologyRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_device_semantic_code_service():
+    from app.core.data_research.device_semantic_repository import (
+        SqlAlchemyDeviceSemanticCodeRepository,
+    )
+    from app.core.data_research.device_semantics import (
+        DeviceSemanticCodeService,
+    )
+    from app.core.data_research.ontology.repository import (
+        SqlAlchemyOntologyRepository,
+    )
+
+    ontology_repository = SqlAlchemyOntologyRepository(db.session)
+    authorizer = _device_ontology_authorizer(ontology_repository)
+    return DeviceSemanticCodeService(
+        SqlAlchemyDeviceSemanticCodeRepository(db.session),
+        review_authorizer=authorizer.assert_accountable,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_device_entity_resolution_service():
+    from app.core.data_research.device_entity_repository import (
+        SqlAlchemyDeviceEntityResolutionRepository,
+    )
+    from app.core.data_research.device_entity_resolution import (
+        DeviceEntityForbidden,
+        DeviceEntityResolutionService,
+    )
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "device_mapping",
+            "DEVICE_ENTITY_RESOLUTION",
+        )
+        accountable = [
+            item
+            for item in matrix.get("assignments", [])
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        ]
+        if len(accountable) != 1 or str(accountable[0].get("user_id")) != str(
+            actor_uid
+        ):
+            raise DeviceEntityForbidden(
+                "only the accountable device mapping asset manager may decide"
+            )
+
+    return DeviceEntityResolutionService(
+        SqlAlchemyDeviceEntityResolutionRepository(db.session),
+        review_authorizer=assert_accountable,
+        auto_merge_enabled=current_app.config.get(
+            "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+            False,
+        ),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_device_quality_service():
+    from app.core.data_research.device_quality import DeviceQualityService
+    from app.core.data_research.device_quality_repository import (
+        SqlAlchemyDeviceQualityRepository,
+    )
+    from app.core.data_research.errors import DeviceQualityForbidden
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "device_quality",
+            "DEVICE_QUALITY",
+        )
+        accountable = [
+            item
+            for item in matrix.get("assignments", [])
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        ]
+        if len(accountable) != 1 or str(accountable[0].get("user_id")) != str(
+            actor_uid
+        ):
+            raise DeviceQualityForbidden(
+                "only the accountable device quality asset manager may publish"
+            )
+
+    return DeviceQualityService(
+        SqlAlchemyDeviceQualityRepository(db.session),
+        publish_authorizer=assert_accountable,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_quality_issue_service():
+    from app.core.data_research.errors import QualityIssueForbidden
+    from app.core.data_research.quality_issue_repository import (
+        SqlAlchemyQualityIssueRepository,
+    )
+    from app.core.data_research.quality_issues import QualityIssueService
+    from app.core.governance.responsibilities import (
+        ResponsibilityService,
+        SqlAlchemyResponsibilityRepository,
+    )
+
+    repository = SqlAlchemyQualityIssueRepository(db.session)
+    responsibilities = ResponsibilityService(
+        SqlAlchemyResponsibilityRepository(db.session)
+    )
+
+    def assert_accountable(actor_uid):
+        matrix = responsibilities.get(
+            "quality_issue",
+            "DEVICE_QUALITY_ISSUES",
+        )
+        accountable = [
+            item
+            for item in matrix.get("assignments", [])
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        ]
+        if len(accountable) != 1 or str(accountable[0].get("user_id")) != str(
+            actor_uid
+        ):
+            raise QualityIssueForbidden(
+                "only the accountable quality issue asset manager may review"
+            )
+
+    return QualityIssueService(
+        repository,
+        review_authorizer=assert_accountable,
+        is_admin=lambda actor_uid: repository.user_has_role(
+            actor_uid,
+            "admin",
+        ),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_device_observability_service():
+    from app.core.data_research.device_observability import (
+        DeviceObservabilityService,
+    )
+    from app.core.data_research.device_observability_repository import (
+        SqlAlchemyDeviceObservabilityRepository,
+    )
+
+    return DeviceObservabilityService(
+        SqlAlchemyDeviceObservabilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_candidate_decision_service():
+    from app.core.data_research.candidate_decisions import CandidateDecisionService
+    from app.core.data_research.data_elements import DataElementService
+    from app.core.data_research.repository import (
+        SqlAlchemyCandidateDecisionRepository,
+        SqlAlchemyDataElementRepository,
+    )
+
+    return CandidateDecisionService(
+        SqlAlchemyCandidateDecisionRepository(db.session),
+        data_elements=DataElementService(SqlAlchemyDataElementRepository(db.session)),
+    )
+
+
+def _artifact_storage():
+    from minio import Minio
+
+    from app.core.data_research.artifacts import MinioArtifactStorage
+
+    client = Minio(
+        current_app.config["MINIO_HOST"],
+        access_key=current_app.config["MINIO_USER"],
+        secret_key=current_app.config["MINIO_PASSWORD"],
+        secure=bool(current_app.config.get("MINIO_SECURE")),
+    )
+    return MinioArtifactStorage(client, current_app.config["MINIO_BUCKET"])
+
+
+def get_artifact_service():
+    from app.core.common.identifiers import new_governance_uid
+    from app.core.data_research.artifacts import (
+        ArtifactService,
+        SqlAlchemyArtifactRepository,
+    )
+    from app.core.data_research.file_policy import FilePolicy
+
+    return ArtifactService(
+        SqlAlchemyArtifactRepository(db.session),
+        _artifact_storage(),
+        FilePolicy(
+            max_bytes=int(current_app.config.get("DATA_RESEARCH_FILE_MAX_BYTES", 25 * 1024 * 1024)),
+            max_pages=int(current_app.config.get("DATA_RESEARCH_FILE_MAX_PAGES", 500)),
+        ),
+        uid_factory=new_governance_uid,
+    )
+
+
+def get_evidence_service():
+    from app.core.data_research.artifacts import EvidenceService
+
+    return EvidenceService(db.session, _artifact_storage())
+
+
+def get_ontology_service():
+    from app.core.data_research.ontology.publication import (
+        OntologyApplicationService,
+        OntologyPublicationService,
+    )
+    from app.core.data_research.ontology.repository import SqlAlchemyOntologyRepository
+    from app.core.events.outbox import enqueue_outbox
+
+    repository = SqlAlchemyOntologyRepository(db.session)
+    authorizer = _device_ontology_authorizer(repository)
+    publication = OntologyPublicationService(
+        repository,
+        outbox_enqueue=lambda **event: enqueue_outbox(db.session, **event),
+        publication_authorizer=authorizer,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+    return OntologyApplicationService(
+        repository,
+        publication=publication,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_ontology_dynamic_service():
+    from app.core.data_research.ontology.change_sets import (
+        SqlAlchemyDynamicOntologyService,
+    )
+
+    return SqlAlchemyDynamicOntologyService(db.session)
+
+
+def get_ontology_exchange_service():
+    from app.core.data_research.ontology.exchange import OntologyExchangeService
+    from app.core.data_research.ontology.repository import SqlAlchemyOntologyRepository
+
+    return OntologyExchangeService(
+        SqlAlchemyOntologyRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_semantic_query_service():
+    from app.core.data_research.ontology.query import (
+        Neo4jSemanticRepository,
+        SemanticQueryService,
+    )
+    from app.services.neo4j_driver import neo4j_driver
+
+    return SemanticQueryService(Neo4jSemanticRepository(neo4j_driver))
+
+
+def _identity():
+    return getattr(g, "current_user", {}) or {}
+
+
+def get_governance_metrics_service():
+    from app.core.data_research.governance_metric_repository import (
+        SqlAlchemyGovernanceMetricRepository,
+    )
+    from app.core.data_research.governance_metrics import (
+        GovernanceMetricsService,
+    )
+
+    return GovernanceMetricsService(
+        SqlAlchemyGovernanceMetricRepository(db.session)
+    )
+
+
+def _governance_metric_access():
+    from app.core.data_research.governance_metrics import (
+        GovernanceMetricAccess,
+    )
+    from app.core.knowledge.access import build_access_context
+
+    context = build_access_context(
+        db.session,
+        identity=_identity(),
+        requested_business_domains=None,
+        correlation_id=str(uuid.uuid4()),
+    )
+    return GovernanceMetricAccess(
+        global_access=context.global_access,
+        business_domain_uids=tuple(context.business_domain_uids),
+    )
+
+
+def _record(record):
+    return {
+        "uid": str(record.uid),
+        "source_uid": str(record.source_uid),
+        "artifact_uid": str(record.artifact_uid) if record.artifact_uid else None,
+        "job_type": record.job_type,
+        "parser_version": record.parser_version,
+        "status": record.status,
+        "parameters": dict(record.parameters or {}),
+        "statistics": dict(record.statistics or {}),
+        "last_error": record.last_error,
+        "attempt_count": int(record.attempt_count or 0),
+        "failure_stage": record.failure_stage,
+        "actor_uid": record.actor_uid,
+        "created_at": record.created_at.isoformat() if record.created_at else None,
+        "updated_at": record.updated_at.isoformat() if record.updated_at else None,
+        "started_at": record.started_at.isoformat() if record.started_at else None,
+        "finished_at": record.finished_at.isoformat() if record.finished_at else None,
+    }
+
+
+def _catalog_snapshot(record):
+    return {
+        "uid": str(record.uid),
+        "job_uid": str(record.job_uid),
+        "source_uid": str(record.source_uid),
+        "attempt": int(record.attempt),
+        "database_type": record.database_type,
+        "content_hash": record.content_hash,
+        "snapshot": dict(record.snapshot or {}),
+        "evidence_count": int(record.evidence_count),
+        "created_at": (
+            record.created_at.isoformat()
+            if record.created_at
+            else None
+        ),
+    }
+
+
+def _element(record):
+    return {
+        "uid": str(record.uid),
+        "code": record.code,
+        "status": record.status,
+        "current_version": int(record.current_version),
+        "snapshot": dict(record.snapshot or {}),
+        "created_by": record.created_by,
+        "updated_by": record.updated_by,
+    }
+
+
+def _decision(record):
+    return {
+        "uid": str(record.uid),
+        "candidate_uid": str(record.candidate_uid),
+        "action": record.action,
+        "data_element_uid": (
+            str(record.data_element_uid) if record.data_element_uid else None
+        ),
+        "evidence_uids": list(record.evidence_uids),
+        "actor_uid": record.actor_uid,
+        "reason": record.reason,
+    }
+
+
+def _artifact(record):
+    return {
+        "uid": str(record.uid),
+        "source_uid": str(record.source_uid),
+        "filename": record.filename,
+        "media_type": record.media_type,
+        "size_bytes": int(record.size_bytes),
+        "content_hash": record.content_hash,
+        "parser_version": record.parser_version,
+    }
+
+
+def _ontology(record):
+    return {
+        "uid": str(record.uid),
+        "code": record.code,
+        "name": record.name,
+        "owner_uid": record.owner_uid,
+        "status": record.status,
+        "draft_revision": int(record.draft_revision),
+        "active_version_uid": record.active_version_uid,
+        "domain_links": [
+            {"domain_uid": link.domain_uid, "role": link.role}
+            for link in record.domain_links
+        ],
+    }
+
+
+def _ontology_version(record):
+    return {
+        "uid": str(record.uid),
+        "ontology_uid": str(record.ontology_uid),
+        "version": int(record.version),
+        "parent_version_uid": record.parent_version_uid,
+        "status": record.status,
+        "content_hash": record.content_hash,
+        "graph_document": record.graph_document.to_dict(),
+        "created_by": record.created_by,
+    }
+
+
+def _iso(value):
+    return value.isoformat() if value else None
+
+
+def _device_asset(record):
+    return {
+        "uid": str(record.uid),
+        "asset_type": record.asset_type,
+        "name": record.name,
+        "status": record.status,
+        "current_version": int(record.current_version),
+        "location": record.location,
+        "organization": record.organization,
+        "responsible_person": record.responsible_person,
+        "attributes": dict(record.attributes or {}),
+        "created_by": record.created_by,
+        "updated_by": record.updated_by,
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+    }
+
+
+def _device_asset_mapping(record):
+    return {
+        "uid": str(record.uid),
+        "asset_uid": str(record.asset_uid),
+        "source_uid": str(record.source_uid),
+        "source_entity": record.source_entity,
+        "asset_type": record.asset_type,
+        "source_code": record.source_code,
+        "source_updated_at": _iso(record.source_updated_at),
+        "first_seen_at": _iso(record.first_seen_at),
+        "last_seen_at": _iso(record.last_seen_at),
+    }
+
+
+def _device_asset_detail(detail):
+    data = _device_asset(detail.asset)
+    data["source_mappings"] = [
+        _device_asset_mapping(mapping)
+        for mapping in detail.mappings
+    ]
+    return data
+
+
+def _device_asset_version(record):
+    return {
+        "uid": str(record.uid),
+        "asset_uid": str(record.asset_uid),
+        "version": int(record.version),
+        "snapshot": dict(record.snapshot or {}),
+        "source_mapping_uid": str(record.source_mapping_uid),
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_asset_import_result(result):
+    return {
+        "records": [
+            {
+                "action": item.action,
+                "asset": _device_asset(item.asset),
+                "source_mapping": _device_asset_mapping(item.mapping),
+            }
+            for item in result.items
+        ],
+        "created_count": int(result.created_count),
+        "updated_count": int(result.updated_count),
+        "unchanged_count": int(result.unchanged_count),
+    }
+
+
+def _device_semantic_profile(result):
+    if result is None:
+        return {
+            "bootstrapped": False,
+            "ontology": None,
+            "version": None,
+            "profile": {
+                "ready_to_publish": False,
+                "class_count": 0,
+                "relation_count": 0,
+                "mapping_count": 0,
+                "missing_classes": [],
+                "missing_relations": [],
+                "missing_mappings": [],
+            },
+        }
+    profile = result.profile
+    return {
+        "bootstrapped": True,
+        "created": bool(result.created),
+        "ontology": _ontology(result.ontology),
+        "version": _ontology_version(result.version),
+        "profile": {
+            "ready_to_publish": bool(profile.ready_to_publish),
+            "class_count": int(profile.class_count),
+            "relation_count": int(profile.relation_count),
+            "mapping_count": int(profile.mapping_count),
+            "missing_classes": list(profile.missing_classes),
+            "missing_relations": list(profile.missing_relations),
+            "missing_mappings": list(profile.missing_mappings),
+        },
+    }
+
+
+def _device_semantic_code(record):
+    return {
+        "uid": str(record.uid),
+        "ontology_uid": str(record.ontology_uid),
+        "code_type": record.code_type,
+        "canonical_code": record.canonical_code,
+        "canonical_name": record.canonical_name,
+        "definition": record.definition,
+        "status": record.status,
+        "current_version": int(record.current_version),
+        "source_mappings": [
+            dict(item) for item in record.source_mappings
+        ],
+        "evidence_uids": list(record.evidence_uids),
+        "suggestion_source": record.suggestion_source,
+        "confidence": record.confidence,
+        "created_by": record.created_by,
+        "updated_by": record.updated_by,
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+    }
+
+
+def _device_semantic_version(record):
+    return {
+        "uid": str(record.uid),
+        "code_uid": str(record.code_uid),
+        "version": int(record.version),
+        "snapshot": dict(record.snapshot),
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_semantic_review(record):
+    return {
+        "uid": str(record.uid),
+        "code_uid": str(record.code_uid),
+        "version": int(record.version),
+        "decision": record.decision,
+        "reason": record.reason,
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_candidate(record):
+    return {
+        "uid": str(record.uid),
+        "left_asset_uid": str(record.left_asset_uid),
+        "right_asset_uid": str(record.right_asset_uid),
+        "canonical_asset_uid": (
+            str(record.canonical_asset_uid)
+            if record.canonical_asset_uid
+            else None
+        ),
+        "status": record.status,
+        "suggestion_source": record.suggestion_source,
+        "confidence": float(record.confidence),
+        "explanation": [
+            dict(item) for item in record.explanation
+        ],
+        "evidence_uids": list(record.evidence_uids),
+        "model_provider": record.model_provider,
+        "model_name": record.model_name,
+        "current_version": int(record.current_version),
+        "created_by": record.created_by,
+        "reviewed_by": record.reviewed_by,
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+    }
+
+
+def _device_entity_review(record):
+    return {
+        "uid": str(record.uid),
+        "candidate_uid": str(record.candidate_uid),
+        "version": int(record.version),
+        "decision": record.decision,
+        "reason": record.reason,
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_merge(record):
+    return {
+        "uid": str(record.uid),
+        "candidate_uid": str(record.candidate_uid),
+        "canonical_asset_uid": str(record.canonical_asset_uid),
+        "member_asset_uid": str(record.member_asset_uid),
+        "review_uid": str(record.review_uid),
+        "snapshot": dict(record.snapshot),
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_rollback(record):
+    return {
+        "uid": str(record.uid),
+        "merge_uid": str(record.merge_uid),
+        "candidate_uid": str(record.candidate_uid),
+        "reason": record.reason,
+        "snapshot": dict(record.snapshot),
+        "actor_uid": record.actor_uid,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_entity_generation(result):
+    return {
+        "records": [
+            _device_entity_candidate(record)
+            for record in result.records
+        ],
+        "created_count": int(result.created_count),
+        "existing_count": int(result.existing_count),
+        "evaluated_pair_count": int(result.evaluated_pair_count),
+        "auto_merged_count": int(result.auto_merged_count),
+        "auto_merge_enabled": bool(
+            current_app.config.get(
+                "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+                False,
+            )
+        ),
+    }
+
+
+def _device_quality_version(record):
+    if record is None:
+        return None
+    return {
+        "uid": str(record.uid),
+        "profile_uid": str(record.profile_uid),
+        "version": int(record.version),
+        "status": record.status,
+        "rules": [dict(item) for item in record.rules],
+        "content_hash": record.content_hash,
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+        "published_by": record.published_by,
+        "published_at": _iso(record.published_at),
+    }
+
+
+def _device_quality_run(record):
+    return {
+        "uid": str(record.uid),
+        "policy_version_uid": str(record.policy_version_uid),
+        "policy_hash": record.policy_hash,
+        "source_uid": (
+            str(record.source_uid) if record.source_uid else None
+        ),
+        "status": record.status,
+        "total_assets": int(record.total_assets),
+        "total_violations": int(record.total_violations),
+        "score": float(record.score),
+        "created_by": record.created_by,
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_quality_rule_result(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "weight": float(record.weight),
+        "status": record.status,
+        "evaluated_count": int(record.evaluated_count),
+        "violation_count": int(record.violation_count),
+        "sampled_count": int(record.sampled_count),
+        "pass_rate": float(record.pass_rate),
+        "weighted_score": float(record.weighted_score),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_quality_violation(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "asset_uid": str(record.asset_uid),
+        "field_name": record.field_name,
+        "source_uid": (
+            str(record.source_uid) if record.source_uid else None
+        ),
+        "source_mapping_uid": (
+            str(record.source_mapping_uid)
+            if record.source_mapping_uid
+            else None
+        ),
+        "message": record.message,
+        "evidence": dict(record.evidence or {}),
+        "created_at": _iso(record.created_at),
+        "expires_at": _iso(record.expires_at),
+    }
+
+
+def _device_quality_asset_score(record):
+    return {
+        "uid": str(record.uid),
+        "run_uid": str(record.run_uid),
+        "asset_uid": str(record.asset_uid),
+        "asset_type": record.asset_type,
+        "status": record.status,
+        "evaluated_rule_count": int(record.evaluated_rule_count),
+        "violation_count": int(record.violation_count),
+        "score": float(record.score),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _quality_issue(record):
+    return {
+        "uid": str(record.uid),
+        "issue_code": record.issue_code,
+        "source_violation_uid": str(record.source_violation_uid),
+        "source_run_uid": str(record.source_run_uid),
+        "rule_code": record.rule_code,
+        "severity": record.severity,
+        "priority": record.priority,
+        "asset_uid": str(record.asset_uid),
+        "field_name": record.field_name,
+        "source_uid": (
+            str(record.source_uid) if record.source_uid else None
+        ),
+        "source_mapping_uid": (
+            str(record.source_mapping_uid)
+            if record.source_mapping_uid else None
+        ),
+        "message": record.message,
+        "evidence": dict(record.evidence or {}),
+        "recurrence_key": record.recurrence_key,
+        "occurrence_number": int(record.occurrence_number),
+        "status": record.status,
+        "assignee_uid": (
+            str(record.assignee_uid) if record.assignee_uid else None
+        ),
+        "due_at": _iso(record.due_at),
+        "is_overdue": bool(record.is_overdue(now_china())),
+        "current_version": int(record.current_version),
+        "created_by": str(record.created_by),
+        "updated_by": str(record.updated_by),
+        "created_at": _iso(record.created_at),
+        "updated_at": _iso(record.updated_at),
+        "closed_at": _iso(record.closed_at),
+    }
+
+
+def _quality_issue_remediation(record):
+    if record is None:
+        return None
+    return {
+        "uid": str(record.uid),
+        "issue_uid": str(record.issue_uid),
+        "round_number": int(record.round_number),
+        "summary": record.summary,
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "submitted_by": str(record.submitted_by),
+        "submitted_at": _iso(record.submitted_at),
+        "review_status": record.review_status,
+        "reviewed_by": (
+            str(record.reviewed_by) if record.reviewed_by else None
+        ),
+        "reviewed_at": _iso(record.reviewed_at),
+        "review_note": record.review_note,
+        "verification_run_uid": (
+            str(record.verification_run_uid)
+            if record.verification_run_uid else None
+        ),
+    }
+
+
+def _quality_issue_timeline(record):
+    return {
+        "uid": str(record.uid),
+        "issue_uid": str(record.issue_uid),
+        "action": record.action,
+        "from_status": record.from_status,
+        "to_status": record.to_status,
+        "actor_uid": str(record.actor_uid),
+        "note": record.note,
+        "payload": dict(record.payload or {}),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_operational_event(record):
+    return {
+        "uid": str(record.uid),
+        "source_uid": str(record.source_uid),
+        "source_entity": record.source_entity,
+        "source_code": record.source_code,
+        "event_type": record.event_type,
+        "asset_uid": str(record.asset_uid),
+        "component_uid": (
+            str(record.component_uid) if record.component_uid else None
+        ),
+        "title": record.title,
+        "severity": record.severity,
+        "status": record.status,
+        "occurred_at": _iso(record.occurred_at),
+        "ended_at": _iso(record.ended_at),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "content_hash": record.content_hash,
+        "created_by": str(record.created_by),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_evidence_relation(record):
+    return {
+        "uid": str(record.uid),
+        "from_kind": record.from_kind,
+        "from_uid": str(record.from_uid),
+        "relation_type": record.relation_type,
+        "to_kind": record.to_kind,
+        "to_uid": str(record.to_uid),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+        "source": record.source,
+        "created_by": str(record.created_by),
+        "created_at": _iso(record.created_at),
+    }
+
+
+def _device_graph_node(record):
+    return {
+        "kind": record.kind,
+        "uid": str(record.uid),
+        "node_type": record.node_type,
+        "label": record.label,
+        "occurred_at": _iso(record.occurred_at),
+        "evidence_refs": [dict(item) for item in record.evidence_refs],
+    }
+
+
+def _device_evidence_graph(record):
+    return {
+        "anchor_kind": record.anchor_kind,
+        "anchor_uid": str(record.anchor_uid),
+        "nodes": [_device_graph_node(item) for item in record.nodes],
+        "relations": [
+            _device_evidence_relation(item) for item in record.relations
+        ],
+        "truncated": bool(record.truncated),
+    }
+
+
+def _root_cause_analysis(record):
+    return {
+        "analysis_status": record.analysis_status,
+        "conclusion": record.conclusion,
+        "anchor": _device_graph_node(record.anchor),
+        "candidates": [
+            {
+                "event_uid": str(item.event_uid),
+                "event_type": item.event_type,
+                "title": item.title,
+                "occurred_at": _iso(item.occurred_at),
+                "support_level": item.support_level,
+                "path_node_uids": list(item.path_node_uids),
+                "path_relation_uids": list(item.path_relation_uids),
+                "path_relation_types": list(item.path_relation_types),
+                "evidence_refs": [
+                    dict(evidence) for evidence in item.evidence_refs
+                ],
+            }
+            for item in record.candidates
+        ],
+        "graph": _device_evidence_graph(record.graph),
+        "limitations": list(record.limitations),
+        "generated_at": _iso(record.generated_at),
+    }
+
+
+def _device_asset_page(name, *, default, maximum):
+    from app.core.data_research.errors import DeviceAssetInvalid
+
+    raw = request.args.get(name)
+    try:
+        value = default if raw in (None, "") else int(raw)
+    except (TypeError, ValueError) as error:
+        raise DeviceAssetInvalid(f"{name} must be an integer") from error
+    if value < 1 or value > maximum:
+        raise DeviceAssetInvalid(
+            f"{name} must be between 1 and {maximum}"
+        )
+    return value
+
+
+def _error(error):
+    if isinstance(error, DataResearchError):
+        return (
+            jsonify(
+                failed(
+                    str(error),
+                    code=error.http_status,
+                    error={"code": error.code},
+                )
+            ),
+            error.http_status,
+        )
+    logger.exception("data-research ingestion request failed")
+    return (
+        jsonify(
+            failed(
+                "数据采集任务处理失败",
+                code=500,
+                error={"code": "DATA_RESEARCH_ERROR"},
+            )
+        ),
+        500,
+    )
+
+
+@bp.route("/ingestion-jobs", methods=["POST"])
+def create_ingestion_job():
+    payload = request.get_json(silent=True) or {}
+    try:
+        actor_uid = _identity().get("id") or _identity().get("sub")
+        if payload.get("job_type") == "catalog_collect":
+            get_database_source_registration_service().ensure(
+                payload.get("source_uid"),
+                actor_uid=actor_uid,
+            )
+        record, created = get_ingestion_service().create_job(
+            payload,
+            actor_uid=actor_uid,
+        )
+        return jsonify(success(_record(record))), 201 if created else 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ingestion-jobs", methods=["GET"])
+def list_ingestion_jobs():
+    filters = {
+        name: request.args.get(name)
+        for name in ("status", "source_uid")
+        if request.args.get(name)
+    }
+    try:
+        records = get_ingestion_service().list_jobs(filters)
+        return jsonify(
+            success({"records": [_record(item) for item in records], "total": len(records)})
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ingestion-jobs/<job_uid>", methods=["GET"])
+def get_ingestion_job(job_uid):
+    try:
+        return jsonify(success(_record(get_ingestion_service().get_job(job_uid)))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ingestion-jobs/<job_uid>/execute", methods=["POST"])
+def execute_ingestion_job(job_uid):
+    try:
+        record = get_catalog_ingestion_executor().execute(job_uid)
+        return jsonify(success(_record(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/ingestion-jobs/<job_uid>/catalog-snapshots",
+    methods=["GET"],
+)
+def list_catalog_snapshots(job_uid):
+    try:
+        records = get_catalog_snapshot_repository().list(job_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _catalog_snapshot(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ingestion-jobs/<job_uid>/evidence", methods=["GET"])
+def list_ingestion_job_evidence(job_uid):
+    try:
+        records = get_evidence_service().list_for_job(job_uid)
+        return jsonify(
+            success({"records": records, "total": len(records)})
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ingestion-jobs/<job_uid>/retry", methods=["POST"])
+def retry_ingestion_job(job_uid):
+    try:
+        return jsonify(success(_record(get_ingestion_service().retry(job_uid)))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ingestion-jobs/<job_uid>/cancel", methods=["POST"])
+def cancel_ingestion_job(job_uid):
+    try:
+        service = get_ingestion_service()
+        record = service.get_job(job_uid)
+        identity = _identity()
+        permissions = set(identity.get("permissions") or [])
+        actor_uid = identity.get("id") or identity.get("sub")
+        if record.actor_uid != actor_uid and "ingestion:admin" not in permissions:
+            return jsonify(failed("权限不足", code=403)), 403
+        return jsonify(success(_record(service.cancel(job_uid)))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/data-elements", methods=["GET"])
+def list_data_elements():
+    try:
+        records = get_data_element_service().list(
+            status=request.args.get("status"),
+            business_domain_uid=request.args.get("business_domain_uid"),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [_element(item) for item in records],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-assets", methods=["GET"])
+def list_device_assets():
+    filters = {
+        name: request.args.get(name)
+        for name in ("keyword", "asset_type", "status", "source_uid")
+        if request.args.get(name)
+    }
+    try:
+        page = _device_asset_page(
+            "page",
+            default=1,
+            maximum=1_000_000,
+        )
+        page_size = _device_asset_page(
+            "page_size",
+            default=20,
+            maximum=100,
+        )
+        records, total = get_device_asset_service().search(
+            filters,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_asset_detail(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": page,
+                    "page_size": page_size,
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-assets/import", methods=["POST"])
+def import_device_assets():
+    try:
+        result = get_device_asset_service().import_records(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_asset_import_result(result))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-assets/<asset_uid>", methods=["GET"])
+def get_device_asset(asset_uid):
+    try:
+        return jsonify(
+            success(
+                _device_asset_detail(
+                    get_device_asset_service().get(asset_uid)
+                )
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-assets/<asset_uid>/versions", methods=["GET"])
+def list_device_asset_versions(asset_uid):
+    try:
+        records = get_device_asset_service().versions(asset_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_asset_version(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/bootstrap", methods=["POST"])
+def bootstrap_device_semantics():
+    try:
+        result = get_device_semantic_service().bootstrap(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return (
+            jsonify(success(_device_semantic_profile(result))),
+            201 if result.created else 200,
+        )
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/profile", methods=["GET"])
+def get_device_semantic_profile():
+    try:
+        result = get_device_semantic_service().profile()
+        return jsonify(success(_device_semantic_profile(result))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes", methods=["GET"])
+def list_device_semantic_codes():
+    filters = {
+        name: request.args.get(name)
+        for name in ("ontology_uid", "code_type", "status", "keyword")
+        if request.args.get(name)
+    }
+    try:
+        records, total = get_device_semantic_code_service().search(
+            filters,
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_code(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(request.args.get("page", 1)),
+                    "page_size": int(request.args.get("page_size", 20)),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes", methods=["POST"])
+def create_device_semantic_code():
+    try:
+        record = get_device_semantic_code_service().create(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-semantics/codes/<code_uid>", methods=["GET"])
+def get_device_semantic_code(code_uid):
+    try:
+        record = get_device_semantic_code_service().get(code_uid)
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/revisions",
+    methods=["POST"],
+)
+def revise_device_semantic_code(code_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_device_semantic_code_service().revise(
+            code_uid,
+            payload,
+            expected_version=payload.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/submit",
+    methods=["POST"],
+)
+def submit_device_semantic_code(code_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_device_semantic_code_service().submit(
+            code_uid,
+            expected_version=payload.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_semantic_code(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/review",
+    methods=["POST"],
+)
+def review_device_semantic_code(code_uid):
+    try:
+        record, review = get_device_semantic_code_service().review(
+            code_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(
+            success(
+                {
+                    "record": _device_semantic_code(record),
+                    "review": _device_semantic_review(review),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/versions",
+    methods=["GET"],
+)
+def list_device_semantic_code_versions(code_uid):
+    try:
+        records = get_device_semantic_code_service().versions(code_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_version(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-semantics/codes/<code_uid>/reviews",
+    methods=["GET"],
+)
+def list_device_semantic_code_reviews(code_uid):
+    try:
+        records = get_device_semantic_code_service().reviews(code_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_semantic_review(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates", methods=["GET"])
+def list_device_entity_candidates():
+    filters = {
+        name: request.args.get(name)
+        for name in ("status", "suggestion_source")
+        if request.args.get(name)
+    }
+    try:
+        records, total = get_device_entity_resolution_service().search(
+            filters,
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_candidate(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(request.args.get("page", 1)),
+                    "page_size": int(request.args.get("page_size", 20)),
+                    "auto_merge_enabled": bool(
+                        current_app.config.get(
+                            "DEVICE_ENTITY_AUTO_MERGE_ENABLED",
+                            False,
+                        )
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates/generate", methods=["POST"])
+def generate_device_entity_candidates():
+    try:
+        result = get_device_entity_resolution_service().generate(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        status = 201 if result.created_count else 200
+        return jsonify(success(_device_entity_generation(result))), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates", methods=["POST"])
+def submit_device_entity_candidate():
+    try:
+        record = (
+            get_device_entity_resolution_service().submit_ai_candidate(
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(success(_device_entity_candidate(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-entities/candidates/<candidate_uid>", methods=["GET"])
+def get_device_entity_candidate(candidate_uid):
+    try:
+        record = get_device_entity_resolution_service().get(candidate_uid)
+        return jsonify(success(_device_entity_candidate(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/review",
+    methods=["POST"],
+)
+def review_device_entity_candidate(candidate_uid):
+    try:
+        candidate, review, merge = (
+            get_device_entity_resolution_service().review(
+                candidate_uid,
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(
+            success(
+                {
+                    "candidate": _device_entity_candidate(candidate),
+                    "review": _device_entity_review(review),
+                    "merge": (
+                        _device_entity_merge(merge)
+                        if merge is not None
+                        else None
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/reviews",
+    methods=["GET"],
+)
+def list_device_entity_reviews(candidate_uid):
+    try:
+        records = get_device_entity_resolution_service().reviews(
+            candidate_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_review(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/candidates/<candidate_uid>/merges",
+    methods=["GET"],
+)
+def list_device_entity_merges(candidate_uid):
+    try:
+        records = get_device_entity_resolution_service().merges(
+            candidate_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_merge(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/merges/<merge_uid>/rollback",
+    methods=["POST"],
+)
+def rollback_device_entity_merge(merge_uid):
+    try:
+        candidate, rollback = (
+            get_device_entity_resolution_service().rollback(
+                merge_uid,
+                request.get_json(silent=True) or {},
+                actor_uid=_identity().get("id") or _identity().get("sub"),
+            )
+        )
+        return jsonify(
+            success(
+                {
+                    "candidate": _device_entity_candidate(candidate),
+                    "rollback": _device_entity_rollback(rollback),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-entities/merges/<merge_uid>/rollbacks",
+    methods=["GET"],
+)
+def list_device_entity_rollbacks(merge_uid):
+    try:
+        records = get_device_entity_resolution_service().rollbacks(
+            merge_uid
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_entity_rollback(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/profile", methods=["GET"])
+def get_device_quality_profile():
+    try:
+        result = get_device_quality_service().profile()
+        return jsonify(
+            success(
+                {
+                    "profile_uid": result["profile_uid"],
+                    "name": result["name"],
+                    "latest_version": _device_quality_version(
+                        result["latest_version"]
+                    ),
+                    "active_version": _device_quality_version(
+                        result["active_version"]
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/bootstrap", methods=["POST"])
+def bootstrap_device_quality():
+    try:
+        record = get_device_quality_service().bootstrap(
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/profile/versions", methods=["POST"])
+def revise_device_quality_profile():
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_device_quality_service().revise(
+            rules=body.get("rules"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/profile/versions", methods=["GET"])
+def list_device_quality_versions():
+    try:
+        records = get_device_quality_service().versions()
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_version(record)
+                        for record in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/profile/versions/<version_uid>/publish",
+    methods=["POST"],
+)
+def publish_device_quality_version(version_uid):
+    try:
+        record = get_device_quality_service().publish(
+            version_uid,
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_device_quality_version(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs", methods=["POST"])
+def run_device_quality():
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_device_quality_service().run(
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+            source_uid=body.get("source_uid"),
+        )
+        return jsonify(success(_device_quality_run(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs", methods=["GET"])
+def list_device_quality_runs():
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().runs(
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_run(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/runs/<run_uid>", methods=["GET"])
+def get_device_quality_run(run_uid):
+    try:
+        record, results = get_device_quality_service().get_run(run_uid)
+        return jsonify(
+            success(
+                {
+                    **_device_quality_run(record),
+                    "rule_results": [
+                        _device_quality_rule_result(item)
+                        for item in results
+                    ],
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/runs/<run_uid>/violations",
+    methods=["GET"],
+)
+def list_device_quality_violations(run_uid):
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().violations(
+            run_uid,
+            rule_code=request.args.get("rule_code"),
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_violation(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/runs/<run_uid>/asset-scores",
+    methods=["GET"],
+)
+def list_device_quality_asset_scores(run_uid):
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_quality_service().asset_scores(
+            run_uid,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_quality_asset_score(record)
+                        for record in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues", methods=["GET"])
+def list_quality_issues():
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        overdue_only = str(
+            request.args.get("overdue_only", "")
+        ).strip().lower() in {"1", "true", "yes"}
+        records, total = get_quality_issue_service().issues(
+            status=request.args.get("status"),
+            assignee_uid=request.args.get("assignee_uid"),
+            overdue_only=overdue_only,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [_quality_issue(item) for item in records],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/statistics", methods=["GET"])
+def get_quality_issue_statistics():
+    try:
+        return jsonify(
+            success(get_quality_issue_service().statistics())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/assignees", methods=["GET"])
+def list_quality_issue_assignees():
+    try:
+        records = get_quality_issue_service().assignees()
+        return jsonify(
+            success({"records": list(records), "total": len(records)})
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/import", methods=["POST"])
+def import_quality_issues():
+    try:
+        body = request.get_json(silent=True) or {}
+        result = get_quality_issue_service().import_violations(
+            violation_uids=body.get("violation_uids"),
+            priority=body.get("priority"),
+            due_at=body.get("due_at"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        payload = {
+            "records": [_quality_issue(item) for item in result.records],
+            "created_count": int(result.created_count),
+            "existing_count": int(result.existing_count),
+        }
+        status = 201 if result.created_count else 200
+        return jsonify(success(payload)), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-quality/issues/<issue_uid>", methods=["GET"])
+def get_quality_issue(issue_uid):
+    try:
+        issue, remediation = get_quality_issue_service().get(issue_uid)
+        return jsonify(
+            success(
+                {
+                    **_quality_issue(issue),
+                    "latest_remediation": _quality_issue_remediation(
+                        remediation
+                    ),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/timeline",
+    methods=["GET"],
+)
+def get_quality_issue_timeline(issue_uid):
+    try:
+        records = get_quality_issue_service().timeline(issue_uid)
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _quality_issue_timeline(item) for item in records
+                    ],
+                    "total": len(records),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/assign",
+    methods=["POST"],
+)
+def assign_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().assign(
+            issue_uid,
+            assignee_uid=body.get("assignee_uid"),
+            due_at=body.get("due_at"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/start",
+    methods=["POST"],
+)
+def start_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().start(
+            issue_uid,
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/submit",
+    methods=["POST"],
+)
+def submit_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().submit(
+            issue_uid,
+            summary=body.get("summary"),
+            evidence_refs=body.get("evidence_refs", []),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/review",
+    methods=["POST"],
+)
+def review_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().review(
+            issue_uid,
+            verification_result=body.get("verification_result"),
+            note=body.get("note"),
+            verification_run_uid=body.get("verification_run_uid"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/device-quality/issues/<issue_uid>/reopen",
+    methods=["POST"],
+)
+def reopen_quality_issue(issue_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        record = get_quality_issue_service().reopen(
+            issue_uid,
+            reason=body.get("reason"),
+            due_at=body.get("due_at"),
+            expected_version=body.get("expected_version"),
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_quality_issue(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/events", methods=["GET"])
+def list_device_operational_events():
+    filters = {
+        name: request.args.get(name)
+        for name in (
+            "event_type",
+            "severity",
+            "status",
+            "asset_uid",
+            "source_uid",
+            "keyword",
+        )
+        if request.args.get(name)
+    }
+    try:
+        page = request.args.get("page", 1)
+        page_size = request.args.get("page_size", 20)
+        records, total = get_device_observability_service().events(
+            filters,
+            page=page,
+            page_size=page_size,
+        )
+        return jsonify(
+            success(
+                {
+                    "records": [
+                        _device_operational_event(item)
+                        for item in records
+                    ],
+                    "total": int(total),
+                    "page": int(page),
+                    "page_size": int(page_size),
+                }
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/import", methods=["POST"])
+def import_device_operational_evidence():
+    try:
+        result = get_device_observability_service().import_evidence(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        data = {
+            "events": [
+                _device_operational_event(item) for item in result.events
+            ],
+            "relations": [
+                _device_evidence_relation(item)
+                for item in result.relations
+            ],
+            "created_events": int(result.created_events),
+            "existing_events": int(result.existing_events),
+            "created_relations": int(result.created_relations),
+            "existing_relations": int(result.existing_relations),
+        }
+        status = (
+            201
+            if result.created_events or result.created_relations
+            else 200
+        )
+        return jsonify(success(data)), status
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/graph", methods=["GET"])
+def get_device_evidence_graph():
+    try:
+        record = get_device_observability_service().graph(
+            request.args.get("anchor_kind"),
+            request.args.get("anchor_uid"),
+            max_hops=request.args.get("max_hops", 2),
+        )
+        return jsonify(success(_device_evidence_graph(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/device-observability/root-cause", methods=["GET"])
+def get_device_root_cause():
+    try:
+        record = get_device_observability_service().root_cause(
+            request.args.get("anchor_kind"),
+            request.args.get("anchor_uid"),
+            max_hops=request.args.get("max_hops", 3),
+        )
+        return jsonify(success(_root_cause_analysis(record))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/governance-metrics/summary", methods=["GET"])
+def get_governance_metric_summary():
+    try:
+        result = get_governance_metrics_service().summary(
+            _governance_metric_access()
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/governance-metrics/details", methods=["GET"])
+def get_governance_metric_details():
+    try:
+        result = get_governance_metrics_service().details(
+            _governance_metric_access(),
+            metric=request.args.get("metric"),
+            state=request.args.get("state"),
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/data-elements", methods=["POST"])
+def create_data_element():
+    try:
+        record = get_data_element_service().create_draft(
+            request.get_json(silent=True) or {},
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        db.session.commit()
+        return jsonify(success(_element(record))), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error(error)
+
+
+@bp.route("/data-elements/<element_uid>/transitions", methods=["POST"])
+def transition_data_element(element_uid):
+    payload = request.get_json(silent=True) or {}
+    identity = _identity()
+    target_status = str(payload.get("target_status") or "")
+    if (
+        target_status in {"published", "deprecated", "retired"}
+        and "data-elements:publish" not in set(identity.get("permissions") or [])
+    ):
+        return jsonify(failed("权限不足", code=403)), 403
+    try:
+        record = get_data_element_service().transition(
+            element_uid,
+            target_status,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=identity.get("id") or identity.get("sub"),
+        )
+        db.session.commit()
+        return jsonify(success(_element(record))), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error(error)
+
+
+@bp.route("/candidate-decisions", methods=["POST"])
+def decide_candidates():
+    payload = request.get_json(silent=True) or {}
+    try:
+        records = get_candidate_decision_service().decide(
+            payload.get("decisions") or [],
+            actor_uid=_identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success([_decision(record) for record in records])), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/sources/files", methods=["POST"])
+def upload_source_file():
+    uploaded = request.files.get("file")
+    if uploaded is None:
+        return jsonify(failed("缺少上传文件", code=400)), 400
+    try:
+        record, created = get_artifact_service().store(
+            request.form.get("source_uid"),
+            uploaded.filename,
+            uploaded.mimetype,
+            uploaded.read(),
+            request.form.get("parser_version") or "auto-v1",
+        )
+        db.session.commit()
+        return jsonify(success(_artifact(record))), 201 if created else 200
+    except Exception as error:
+        db.session.rollback()
+        return _error(error)
+
+
+@bp.route("/evidence/<evidence_uid>", methods=["GET"])
+def get_evidence(evidence_uid):
+    identity = _identity()
+    try:
+        service = get_evidence_service()
+        if request.args.get("download") in {"1", "true", "yes"}:
+            if "evidence:download" not in set(identity.get("permissions") or []):
+                return jsonify(failed("权限不足", code=403)), 403
+            content, filename, media_type = service.download(evidence_uid)
+            return send_file(
+                io.BytesIO(content),
+                mimetype=media_type,
+                as_attachment=True,
+                download_name=filename,
+            )
+        preview = service.preview(evidence_uid)
+        from app.core.data_research.artifacts import redact_excerpt
+
+        preview["excerpt"] = redact_excerpt(preview.get("excerpt"))
+        return jsonify(success(preview)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies", methods=["GET"])
+def list_ontologies():
+    try:
+        return jsonify(success([_ontology(item) for item in get_ontology_service().list()])), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies", methods=["POST"])
+def create_ontology():
+    try:
+        record = get_ontology_service().create(
+            request.get_json(silent=True) or {},
+            _identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_ontology(record))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>", methods=["GET"])
+def get_ontology(ontology_uid):
+    try:
+        return jsonify(success(_ontology(get_ontology_service().get(ontology_uid)))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/versions", methods=["GET"])
+def list_ontology_versions(ontology_uid):
+    try:
+        return jsonify(
+            success(
+                [
+                    _ontology_version(item)
+                    for item in get_ontology_service().list_versions(ontology_uid)
+                ]
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/graph", methods=["GET"])
+def get_ontology_graph(ontology_uid):
+    try:
+        service = get_ontology_service()
+        ontology = service.get(ontology_uid)
+        version = service.latest_version(ontology_uid)
+        data = (
+            _ontology_version(version)
+            if version is not None
+            else {
+                "uid": None,
+                "ontology_uid": str(ontology_uid),
+                "version": 0,
+                "parent_version_uid": None,
+                "status": "draft",
+                "content_hash": None,
+                "graph_document": {
+                    name: []
+                    for name in (
+                        "classes",
+                        "properties",
+                        "relations",
+                        "constraints",
+                        "domain_links",
+                        "element_mappings",
+                    )
+                },
+                "created_by": None,
+            }
+        )
+        response = jsonify(success(data))
+        response.headers["ETag"] = f'"{int(ontology.draft_revision)}"'
+        return response, 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/graph", methods=["PATCH"])
+def save_ontology_graph(ontology_uid):
+    etag = str(request.headers.get("If-Match") or "").strip().strip('"')
+    if not etag.isdigit():
+        return jsonify(failed("缺少有效 If-Match 修订号", code=428)), 428
+    try:
+        version = get_ontology_service().save_draft(
+            ontology_uid,
+            request.get_json(silent=True) or {},
+            int(etag),
+            _identity().get("id") or _identity().get("sub"),
+        )
+        response = jsonify(success(_ontology_version(version)))
+        response.headers["ETag"] = f'"{int(etag) + 1}"'
+        return response, 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/validate", methods=["POST"])
+def validate_ontology(ontology_uid):
+    try:
+        issues = get_ontology_service().validate(ontology_uid)
+        data = [
+            {"code": item.code, "message": item.message, "path": item.path}
+            for item in issues
+        ]
+        return jsonify(success(data)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/publish", methods=["POST"])
+def publish_ontology(ontology_uid):
+    try:
+        version = get_ontology_service().publish(
+            ontology_uid,
+            request.headers.get("Idempotency-Key") or f"{ontology_uid}:publish",
+            _identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_ontology_version(version))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/diff", methods=["GET"])
+def diff_ontology(ontology_uid):
+    try:
+        return jsonify(success(get_ontology_service().diff(
+            ontology_uid, request.args.get("left"), request.args.get("right")
+        ))), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/rollback", methods=["POST"])
+def rollback_ontology(ontology_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        version = get_ontology_service().rollback(
+            ontology_uid,
+            payload.get("target_version_uid"),
+            int(payload.get("expected_revision")),
+            _identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(_ontology_version(version))), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/suggestions", methods=["POST"])
+def generate_ontology_suggestions(ontology_uid):
+    try:
+        result = get_ontology_dynamic_service().generate(
+            ontology_uid,
+            request.get_json(silent=True) or {},
+            _identity().get("id") or _identity().get("sub"),
+        )
+        records = (
+            result.get("suggestions") or []
+            if isinstance(result, dict)
+            else result
+        )
+        suggestions = [
+            item
+            if isinstance(item, dict)
+            else {
+                "uid": item.uid,
+                "kind": item.kind,
+                "payload": item.payload,
+                "evidence_uids": list(item.evidence_uids),
+                "confidence": item.confidence,
+                "source": item.source,
+                "model_version": item.model_version,
+                "prompt_version": item.prompt_version,
+            }
+            for item in records
+        ]
+        data = {
+            "change_set_uid": (
+                result.get("change_set_uid")
+                if isinstance(result, dict)
+                else None
+            ),
+            "suggestions": suggestions,
+        }
+        return jsonify(success(data)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route(
+    "/ontologies/<ontology_uid>/change-sets/<change_set_uid>/decisions",
+    methods=["POST"],
+)
+def decide_ontology_change_set(ontology_uid, change_set_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        result = get_ontology_dynamic_service().decide(
+            ontology_uid,
+            change_set_uid,
+            payload.get("decisions") or [],
+            _identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/<ontology_uid>/export", methods=["GET"])
+def export_ontology(ontology_uid):
+    try:
+        content, media_type, filename = get_ontology_exchange_service().export(
+            ontology_uid, request.args.get("format") or "json"
+        )
+        return send_file(
+            io.BytesIO(content),
+            mimetype=media_type,
+            as_attachment=True,
+            download_name=filename,
+        )
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/ontologies/import", methods=["POST"])
+def import_ontology():
+    try:
+        uploaded = request.files.get("file")
+        content = uploaded.read() if uploaded is not None else request.get_data(cache=False)
+        result = get_ontology_exchange_service().import_document(
+            content,
+            request.args.get("format") or "json",
+            _identity().get("id") or _identity().get("sub"),
+        )
+        return jsonify(success(result)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic/properties/<property_uid>", methods=["GET"])
+def query_semantic_property(property_uid):
+    domain = str(request.args.get("business_domain_uid") or "").strip()
+    identity = _identity()
+    scoped = set(identity.get("business_domains") or [domain])
+    if "*" not in scoped and domain not in scoped:
+        return jsonify(failed("权限不足", code=403)), 403
+    try:
+        result = get_semantic_query_service().trace_property(
+            property_uid,
+            allowed_domains={domain},
+            limit=int(request.args.get("limit") or 20),
+            after_uid=request.args.get("after_uid"),
+        )
+        return jsonify(success(result)), 200
+    except Exception as error:
+        return _error(error)

+ 245 - 0
app/api/data_development/semantic_routes.py

@@ -0,0 +1,245 @@
+"""HTTP boundary for governed terms, code sets, metrics and field mappings."""
+
+from __future__ import annotations
+
+import io
+
+from flask import g, jsonify, request, send_file
+
+from app import db
+from app.api.data_development import bp
+from app.models.result import failed, success
+
+
+def get_semantic_governance_service():
+    from app.core.data_research.semantic_governance import (
+        SemanticGovernanceService,
+    )
+    from app.core.data_research.semantic_repository import (
+        SqlAlchemySemanticGovernanceRepository,
+    )
+    from app.core.events.outbox import enqueue_outbox
+
+    return SemanticGovernanceService(
+        SqlAlchemySemanticGovernanceRepository(db.session),
+        outbox_enqueue=lambda **event: enqueue_outbox(db.session, **event),
+    )
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+def _error(error):
+    db.session.rollback()
+    if isinstance(error, LookupError):
+        return jsonify(failed(str(error), code=404)), 404
+    if isinstance(error, ValueError):
+        return jsonify(failed(str(error), code=400)), 400
+    if isinstance(error, RuntimeError):
+        return jsonify(failed(str(error), code=409)), 409
+    return jsonify(failed("语义治理请求处理失败", code=500)), 500
+
+
+@bp.route("/semantic-assets", methods=["GET"])
+def list_semantic_assets():
+    try:
+        records = get_semantic_governance_service().list(
+            asset_kind=request.args.get("asset_kind"),
+            status=request.args.get("status"),
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets", methods=["POST"])
+def create_semantic_asset():
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().create_draft(
+            payload.get("asset_kind"),
+            {key: value for key, value in payload.items() if key != "asset_kind"},
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>", methods=["GET"])
+def get_semantic_asset(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().get(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/versions", methods=["GET"])
+def list_semantic_asset_versions(asset_uid):
+    try:
+        return jsonify(
+            success(
+                get_semantic_governance_service().list_versions(asset_uid)
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/reviews", methods=["GET"])
+def list_semantic_asset_reviews(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().list_reviews(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/audits", methods=["GET"])
+def list_semantic_asset_audits(asset_uid):
+    try:
+        return jsonify(
+            success(get_semantic_governance_service().list_audits(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/revisions", methods=["POST"])
+def revise_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().revise(
+            asset_uid,
+            payload.get("definition") or {},
+            expected_version=int(payload.get("expected_version")),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/submit", methods=["POST"])
+def submit_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().submit(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/review", methods=["POST"])
+def review_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().review(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            decision=payload.get("decision"),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/publish", methods=["POST"])
+def publish_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().publish(
+            asset_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/<asset_uid>/rollback", methods=["POST"])
+def rollback_semantic_asset(asset_uid):
+    payload = request.get_json(silent=True) or {}
+    try:
+        record = get_semantic_governance_service().rollback(
+            asset_uid,
+            target_version=int(payload.get("target_version")),
+            expected_version=int(payload.get("expected_version")),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-field-mappings", methods=["GET"])
+def list_semantic_field_mappings():
+    try:
+        records = get_semantic_governance_service().list_field_mappings(
+            asset_uid=request.args.get("asset_uid"),
+            data_element_uid=request.args.get("data_element_uid"),
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-field-mappings", methods=["POST"])
+def create_semantic_field_mapping():
+    try:
+        record = get_semantic_governance_service().map_physical_field(
+            request.get_json(silent=True) or {},
+            actor_uid=_actor_uid(),
+        )
+        db.session.commit()
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.route("/semantic-assets/export", methods=["GET"])
+def export_semantic_assets():
+    from app.core.data_research.semantic_governance import SemanticExchange
+
+    export_format = str(request.args.get("format") or "json").lower()
+    try:
+        assets = get_semantic_governance_service().list(status="published")
+        exchange = SemanticExchange()
+        if export_format == "rdf":
+            content = exchange.export_rdf(assets)
+            filename = "semantic-governance.rdf"
+            mimetype = "application/rdf+xml"
+        elif export_format == "json":
+            content = exchange.export_json(assets)
+            filename = "semantic-governance.json"
+            mimetype = "application/json"
+        else:
+            raise ValueError("semantic export format is unsupported")
+        return send_file(
+            io.BytesIO(content),
+            mimetype=mimetype,
+            as_attachment=True,
+            download_name=filename,
+        )
+    except Exception as error:
+        return _error(error)

+ 1 - 0
app/api/data_factory/__init__.py

@@ -3,3 +3,4 @@ from flask import Blueprint
 bp = Blueprint("data_factory", __name__)
 
 from app.api.data_factory import routes  # noqa: E402, F401
+from app.api.data_factory import observability_routes  # noqa: E402, F401

+ 246 - 0
app/api/data_factory/observability_routes.py

@@ -0,0 +1,246 @@
+"""HTTP boundary for SLO monitoring and evidence-bound data incidents."""
+
+from __future__ import annotations
+
+import uuid
+from contextlib import suppress
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_factory import bp
+from app.models.result import failed, success
+
+
+def get_data_observability_service():
+    from app.core.events.data_observability import DataObservabilityService
+    from app.core.events.data_observability_repository import (
+        SqlAlchemyDataObservabilityRepository,
+    )
+
+    return DataObservabilityService(
+        SqlAlchemyDataObservabilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def get_production_operations_service():
+    import time
+
+    from app.core.events.production_operations import ProductionOperationsService
+    from app.core.events.production_operations_repository import (
+        SqlAlchemyProductionOperationsRepository,
+    )
+
+    return ProductionOperationsService(
+        SqlAlchemyProductionOperationsRepository(db.session), now=time.time
+    )
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+def _payload():
+    body = request.get_data(cache=True)
+    if len(body) > 4096:
+        raise ValueError("request body exceeds 4096 bytes")
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict):
+        raise ValueError("request body must be an object")
+    return value
+
+
+def _no_store(value, status):
+    response = jsonify(success(value))
+    response.headers["Cache-Control"] = "no-store"
+    return response, status
+
+
+def _error(error):
+    db.session.rollback()
+    if isinstance(error, LookupError):
+        return jsonify(failed(str(error), code=404)), 404
+    if isinstance(error, ValueError):
+        return jsonify(failed(str(error), code=400)), 400
+    if isinstance(error, RuntimeError):
+        return jsonify(failed(str(error), code=409)), 409
+    return jsonify(failed("数据可观测与事故请求处理失败", code=500)), 500
+
+
+def _production_delivery_error(error, service):
+    """Rollback first, then record only a redacted rejection receipt."""
+    db.session.rollback()
+    message = str(error)
+    reason_code = (
+        "conflict"
+        if isinstance(error, RuntimeError)
+        or message in {"idempotency conflict", "delivery queue hard limit reached"}
+        else "invalid_request"
+        if isinstance(error, ValueError)
+        else "internal_error"
+    )
+    with suppress(Exception):
+        service.audit_rejection_independently(
+            actor_uid=_actor_uid(),
+            reason_code=reason_code,
+            correlation_id=str(uuid.uuid4()),
+        )
+    status = {"invalid_request": 400, "conflict": 409, "internal_error": 500}[reason_code]
+    return jsonify(failed(reason_code, code=status)), status
+
+
+@bp.after_app_request
+def _no_store_production_delivery_responses(response):
+    if request.path == "/api/datafactory/observability/operations/deliveries":
+        response.headers["Cache-Control"] = "no-store"
+    return response
+
+
+@bp.get("/observability/overview")
+def get_observability_overview():
+    try:
+        return jsonify(success(get_data_observability_service().overview())), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/slos")
+def list_slo_policies():
+    try:
+        return jsonify(success(get_data_observability_service().list_slos())), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/slos")
+def create_slo_policy():
+    try:
+        record = get_data_observability_service().create_slo(
+            _payload(), actor_uid=_actor_uid()
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/collect")
+def collect_observability_signals():
+    try:
+        _payload()
+        record = get_data_observability_service().collect(
+            actor_uid=_actor_uid()
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/operations/deliveries")
+def enqueue_production_delivery():
+    service = None
+    try:
+        payload = _payload()
+        if set(payload) != {"incident_uid", "alert_uid", "channel", "summary"}:
+            raise ValueError("production delivery request has unsupported fields")
+        service = get_production_operations_service()
+        record = service.enqueue_incident_delivery(
+            payload, actor_uid=_actor_uid()
+        )
+        db.session.commit()
+        return _no_store(record, 201)
+    except Exception as error:
+        return _production_delivery_error(
+            error, service or get_production_operations_service()
+        )
+
+
+@bp.get("/observability/alerts")
+def list_observability_alerts():
+    try:
+        return jsonify(
+            success(get_data_observability_service().list_alerts())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/alerts/<alert_uid>/suppress")
+def suppress_observability_alert(alert_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().suppress_alert(
+            alert_uid,
+            until=payload.get("until"),
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/alerts/<alert_uid>/acknowledge-delivery")
+def acknowledge_observability_alert_delivery(alert_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().acknowledge_delivery(
+            alert_uid,
+            channel=payload.get("channel"),
+            receipt_id=payload.get("receipt_id"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/incidents")
+def list_data_incidents():
+    try:
+        return jsonify(
+            success(get_data_observability_service().list_incidents())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/observability/incidents/<incident_uid>")
+def get_data_incident(incident_uid):
+    try:
+        return jsonify(
+            success(
+                get_data_observability_service().incident_detail(incident_uid)
+            )
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/incidents/<incident_uid>/escalate")
+def escalate_data_incident(incident_uid):
+    try:
+        payload = _payload()
+        record = get_data_observability_service().escalate_incident(
+            incident_uid,
+            reason=payload.get("reason"),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/observability/incidents/<incident_uid>/close")
+def close_data_incident(incident_uid):
+    try:
+        record = get_data_observability_service().close_incident(
+            incident_uid,
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)

+ 36 - 10
app/api/data_flow/routes.py

@@ -1,21 +1,34 @@
 import json
 import logging
 
-from flask import g, jsonify, request
+from flask import current_app, g, jsonify, request
 
 from app import db
 from app.api.data_flow import bp
-from app.core.data_flow.dataflows import DataFlowService
-from app.core.graph.graph_operations import MyEncoder
-from app.models.result import failed, success
 from app.core.data_factory.n8n_client import N8nClient, N8nClientError
+from app.core.data_flow.dataflows import DataFlowService
 from app.core.data_flow.workflow_activation import activate_version
 from app.core.data_flow.workflow_repository import create_version, list_versions
-from app.core.system.permissions import ACTIVATE_WORKFLOW, EDIT_GOVERNANCE, READ_GOVERNANCE, require_permissions
+from app.core.data_rules.repository import DataRuleRepository
+from app.core.graph.graph_operations import MyEncoder
+from app.core.system.permissions import (
+    ACTIVATE_WORKFLOW,
+    EDIT_GOVERNANCE,
+    READ_GOVERNANCE,
+    require_permissions,
+)
+from app.models.result import failed, success
 
 logger = logging.getLogger(__name__)
 
 
+def _repository():
+    configured = current_app.extensions.get("data_rule_repository")
+    if configured is not None:
+        return configured
+    return DataRuleRepository(db.session)
+
+
 @bp.route("/<dataflow_uid>/workflow-versions", methods=["GET"])
 @require_permissions(READ_GOVERNANCE)
 def get_workflow_versions(dataflow_uid):
@@ -114,16 +127,23 @@ def create_dataflow():
         data = request.get_json()
         if not data:
             res = failed("请求数据不能为空", code=400)
-            return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+            return jsonify(res), 400
 
-        result = DataFlowService.create_dataflow(data)
+        result = DataFlowService.create_dataflow(
+            data,
+            repository=_repository(),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
         res = success(result, "数据流创建成功")
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
     except ValueError as ve:
+        db.session.rollback()
         logger.error(f"创建数据流参数错误: {str(ve)}")
         res = failed(f"参数错误: {str(ve)}", code=400)
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+        return jsonify(res), 400
     except Exception as e:
+        db.session.rollback()
         logger.error(f"创建数据流失败: {str(e)}")
         res = failed(f"创建数据流失败: {str(e)}")
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
@@ -136,15 +156,21 @@ def update_dataflow(dataflow_id):
         data = request.get_json()
         if not data:
             res = failed("请求数据不能为空", code=400)
-            return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+            return jsonify(res), 400
 
-        result = DataFlowService.update_dataflow(dataflow_id, data)
+        result = DataFlowService.update_dataflow(
+            dataflow_id, data, repository=_repository()
+        )
         if result:
             res = success(result, "数据流更新成功")
             return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
         else:
             res = failed("数据流不存在", code=404)
             return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+    except ValueError as ve:
+        logger.error(f"更新数据流参数错误: {str(ve)}")
+        res = failed(f"参数错误: {str(ve)}", code=400)
+        return jsonify(res), 400
     except Exception as e:
         logger.error(f"更新数据流失败: {str(e)}")
         res = failed(f"更新数据流失败: {str(e)}")

+ 76 - 21
app/api/data_interface/routes.py

@@ -1,23 +1,77 @@
 import json
 
-from flask import Response, jsonify, request
+from flask import Response, current_app, jsonify, request
 
+from app import db
 from app.api.data_interface import bp
 from app.core.data_interface import interface
+from app.core.data_rules.repository import DataRuleRepository
 from app.core.graph.graph_operations import (
     MyEncoder,
     connect_graph,
     create_or_get_node,
 )
-from app.core.llm import code_generate_standard
 from app.core.meta_data import get_formatted_time, translate_and_parse
 from app.models.result import failed, success
 
 
+def _repository():
+    configured = current_app.extensions.get("data_rule_repository")
+    if configured is not None:
+        return configured
+    return DataRuleRepository(db.session)
+
+
+_STANDARD_WRITE_FIELDS = {
+    "category",
+    "describe",
+    "id",
+    "input",
+    "migration_metadata",
+    "name_zh",
+    "output",
+    "rule_version_id",
+    "scope",
+    "status",
+    "tag",
+}
+
+
+def _legacy_standard_exists(node_id):
+    if isinstance(node_id, bool) or not isinstance(node_id, int):
+        return False
+    query = (
+        "MATCH (n:data_standard) WHERE id(n) = $node_id "
+        "RETURN n.rule_version_id AS rule_version_id"
+    )
+    with connect_graph().session() as session:
+        row = session.run(query, node_id=node_id).single()
+    return row is not None and row.get("rule_version_id") is None
+
+
+def _prepare_standard_write(value, *, is_new):
+    if not isinstance(value, dict):
+        raise ValueError("request body must be an object")
+    if set(value) - _STANDARD_WRITE_FIELDS:
+        raise ValueError("data standard request contains unsupported fields")
+    receiver = dict(value)
+    rule_version_id = receiver.get("rule_version_id")
+    if rule_version_id is None:
+        if is_new or not _legacy_standard_exists(receiver.get("id")):
+            raise ValueError(
+                "new data standard requires a published rule_version_id"
+            )
+    else:
+        _repository().require_published_rule_version(rule_version_id)
+    return receiver
+
+
 @bp.route("/data/standard/add", methods=["POST"])
 def data_standard_add():
     try:
-        receiver = request.get_json()
+        receiver = _prepare_standard_write(
+            request.get_json(silent=True), is_new=True
+        )
         name_zh = receiver["name_zh"]
         name_en = translate_and_parse(name_zh)
         receiver["name_en"] = name_en[0]
@@ -29,6 +83,9 @@ def data_standard_add():
         res = success("", "success")
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
+    except (KeyError, TypeError, ValueError) as e:
+        res = failed(str(e), 400, {})
+        return jsonify(res), 400
     except Exception as e:
         res = failed(str(e), 500, {})
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
@@ -62,29 +119,24 @@ def data_standard_detail():
 
 @bp.route("/data/standard/code", methods=["POST"])
 def data_standard_code():
-    try:
-        receiver = request.get_json()
-        input = receiver["input"]
-        describe = receiver["describe"]
-        output = receiver["output"]
-        relation = {
-            "input_params": input,
-            "output_params": output,
-        }
-        result = code_generate_standard(describe, relation)
-
-        res = success(result, "success")
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
-
-    except Exception as e:
-        res = failed(str(e), 500, {})
-        return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
+    return (
+        jsonify(
+            failed(
+                "旧数据标准代码生成入口已关闭,请发布 RuleVersion 后绑定",
+                410,
+                {"semantics": "read_only_migration"},
+            )
+        ),
+        410,
+    )
 
 
 @bp.route("/data/standard/update", methods=["POST"])
 def data_standard_update():
     try:
-        receiver = request.get_json()
+        receiver = _prepare_standard_write(
+            request.get_json(silent=True), is_new=False
+        )
         name_zh = receiver["name_zh"]
         name_en = translate_and_parse(name_zh)
         receiver["name_en"] = name_en[0]
@@ -95,6 +147,9 @@ def data_standard_update():
         res = success("", "success")
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)
 
+    except (KeyError, TypeError, ValueError) as e:
+        res = failed(str(e), 400, {})
+        return jsonify(res), 400
     except Exception as e:
         res = failed(str(e), 500, {})
         return json.dumps(res, ensure_ascii=False, cls=MyEncoder)

+ 7 - 0
app/api/data_rules/__init__.py

@@ -0,0 +1,7 @@
+from flask import Blueprint
+
+
+bp = Blueprint("data_rules", __name__)
+
+from app.api.data_rules import routes  # noqa: E402, F401
+from app.api.data_rules import quality_routes  # noqa: E402, F401

+ 140 - 0
app/api/data_rules/quality_routes.py

@@ -0,0 +1,140 @@
+"""HTTP boundary for generic, deterministic quality operations."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_rules import bp
+from app.models.result import failed, success
+
+
+def get_quality_operations_service():
+    from app.core.data_rules.quality_operations import QualityOperationsService
+    from app.core.data_rules.quality_repository import (
+        SqlAlchemyQualityOperationsRepository,
+    )
+
+    return QualityOperationsService(
+        SqlAlchemyQualityOperationsRepository(db.session),
+        publish_authorizer=lambda _actor_uid: None,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+def _payload():
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict):
+        raise ValueError("request body must be an object")
+    return value
+
+
+def _error(error):
+    db.session.rollback()
+    if isinstance(error, LookupError):
+        return jsonify(failed(str(error), code=404)), 404
+    if isinstance(error, ValueError):
+        return jsonify(failed(str(error), code=400)), 400
+    if isinstance(error, RuntimeError):
+        return jsonify(failed(str(error), code=409)), 409
+    return jsonify(failed("通用质量运营请求处理失败", code=500)), 500
+
+
+@bp.get("/quality-operations/templates")
+def list_quality_templates():
+    try:
+        return jsonify(
+            success(get_quality_operations_service().list_templates())
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates")
+def create_quality_template():
+    try:
+        record = get_quality_operations_service().create_template(
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates/<template_uid>/revisions")
+def revise_quality_template(template_uid):
+    try:
+        payload = _payload()
+        record = get_quality_operations_service().revise_template(
+            template_uid,
+            payload.get("definition"),
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/templates/<template_uid>/publish")
+def publish_quality_template(template_uid):
+    try:
+        payload = _payload()
+        record = get_quality_operations_service().publish_template(
+            template_uid,
+            expected_version=int(payload.get("expected_version")),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.post("/quality-operations/execute")
+def execute_quality_profile():
+    try:
+        record = get_quality_operations_service().execute(
+            _payload(),
+            actor_uid=_actor_uid(),
+        )
+        return jsonify(success(record)), 201
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/runs")
+def list_quality_runs():
+    try:
+        records = get_quality_operations_service().list_runs(
+            request.args.get("asset_uid")
+        )
+        return jsonify(success(records)), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/runs/<run_uid>")
+def get_quality_run(run_uid):
+    try:
+        return jsonify(
+            success(get_quality_operations_service().get_run(run_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)
+
+
+@bp.get("/quality-operations/assets/<asset_uid>/trend")
+def get_quality_trend(asset_uid):
+    try:
+        return jsonify(
+            success(get_quality_operations_service().trend(asset_uid))
+        ), 200
+    except Exception as error:
+        return _error(error)

+ 1075 - 0
app/api/data_rules/routes.py

@@ -0,0 +1,1075 @@
+"""Governed control-plane endpoints for AI-authored data rules."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import UTC, datetime, timedelta
+from typing import Any
+
+from flask import current_app, g, jsonify, request
+
+from app import db
+from app.api.data_rules import bp
+from app.core.data_rules.authoring import (
+    OpenAICompatibleRuleModel,
+    RuleAuthoringAgent,
+)
+from app.core.data_rules.contracts import (
+    dataflow_spec_hash,
+    rule_spec_hash,
+    standard_spec_hash,
+    validate_dataflow_spec,
+    validate_rule_spec,
+    validate_standard_spec,
+)
+from app.core.data_rules.deployment import (
+    DataFlowDeploymentService,
+    DeploymentOperationError,
+)
+from app.core.data_rules.production_line import resolve_production_line
+from app.core.data_rules.publication import (
+    GenerationReceiptSigner,
+    LogicalRuleCompiler,
+    PhysicalPlanPublicationService,
+    RulePublicationService,
+    RuleValidationRejected,
+    ServerOwnedLogicalDryRunRunner,
+    ServerOwnedPhysicalPreflightRunner,
+    generation_receipt_claims,
+)
+from app.core.data_rules.release import ProductionLineReleaseService
+from app.core.data_rules.repository import DataRuleRepository
+from app.core.data_rules.schema_resolver import (
+    Neo4jSchemaMetadataCatalog,
+    SchemaResolver,
+)
+from app.core.orchestration.engines.kestra import KestraAdapter
+from app.models.result import failed, success
+from app.runner.auth import TaskTokenIssuer
+
+_VALIDATORS = {
+    "rule": (validate_rule_spec, rule_spec_hash),
+    "standard": (validate_standard_spec, standard_spec_hash),
+    "dataflow": (validate_dataflow_spec, dataflow_spec_hash),
+}
+
+
+def _body() -> dict[str, Any]:
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict):
+        raise ValueError("request body must be an object")
+    return value
+
+
+def _bad_request(message: str = "规则请求无效"):
+    return jsonify(failed(message, code=400)), 400
+
+
+def _closed_body(allowed: set[str]) -> dict[str, Any]:
+    body = _body()
+    if set(body) - allowed:
+        raise ValueError("request contains unsupported fields")
+    return body
+
+
+def _repository() -> DataRuleRepository:
+    configured = current_app.extensions.get("data_rule_repository")
+    if configured is not None:
+        return configured
+    return DataRuleRepository(db.session)
+
+
+def _release_service() -> ProductionLineReleaseService:
+    configured = current_app.extensions.get("production_line_release_service")
+    if configured is not None:
+        return configured
+    repository = _repository()
+    resolver = current_app.extensions.get("data_rule_schema_resolver")
+    if resolver is None:
+        catalog = current_app.extensions.get("data_rule_metadata_catalog")
+        resolver = SchemaResolver(catalog or Neo4jSchemaMetadataCatalog(), repository)
+    return ProductionLineReleaseService(repository, schema_resolver=resolver)
+
+
+def _schema_resolver() -> SchemaResolver:
+    configured = current_app.extensions.get("data_rule_schema_resolver")
+    if configured is not None:
+        return configured
+    repository = _repository()
+    catalog = current_app.extensions.get("data_rule_metadata_catalog")
+    return SchemaResolver(catalog or Neo4jSchemaMetadataCatalog(), repository)
+
+
+def _deployment_readiness() -> dict[str, Any]:
+    reasons = []
+    if not current_app.config.get("KESTRA_BASE_URL"):
+        reasons.append("kestra_base_url_missing")
+    if not current_app.config.get("KESTRA_USERNAME"):
+        reasons.append("kestra_username_missing")
+    if not current_app.config.get("KESTRA_PASSWORD"):
+        reasons.append("kestra_password_missing")
+    secret = current_app.config.get("RUNNER_TASK_TOKEN_SECRET")
+    if not isinstance(secret, str) or len(secret.encode("utf-8")) < 32:
+        reasons.append("runner_task_token_secret_invalid")
+    gate_open = bool(current_app.config.get("DATA_FACTORY_ACTIVATION_ENABLED", False))
+    if not gate_open:
+        reasons.append("post_acceptance_activation_gate_closed")
+    return {
+        "ready": not reasons,
+        "gate_open": gate_open,
+        "reasons": reasons,
+        "server_owned": True,
+    }
+
+
+def _deployment_service() -> DataFlowDeploymentService:
+    configured = current_app.extensions.get("dataflow_deployment_service")
+    if configured is not None:
+        return configured
+    readiness = _deployment_readiness()
+    infrastructure_reasons = [
+        reason
+        for reason in readiness["reasons"]
+        if reason != "post_acceptance_activation_gate_closed"
+    ]
+    if infrastructure_reasons:
+        raise RuntimeError("data factory deployment infrastructure is not ready")
+    engine = KestraAdapter(
+        current_app.config["KESTRA_BASE_URL"],
+        current_app.config["KESTRA_USERNAME"],
+        current_app.config["KESTRA_PASSWORD"],
+        tenant_id=current_app.config.get("KESTRA_TENANT_ID", "main"),
+        timeout=int(current_app.config.get("KESTRA_HTTP_TIMEOUT_SECONDS", 30)),
+    )
+    service = DataFlowDeploymentService(
+        _repository(),
+        engine=engine,
+        token_issuer=TaskTokenIssuer(current_app.config["RUNNER_TASK_TOKEN_SECRET"]),
+    )
+    current_app.extensions["dataflow_deployment_service"] = service
+    return service
+
+
+def _activation_gate_ready() -> bool:
+    if current_app.extensions.get("dataflow_deployment_service") is not None:
+        return bool(current_app.config.get("DATA_FACTORY_ACTIVATION_ENABLED", False))
+    return bool(_deployment_readiness()["ready"])
+
+
+def _receipt_signer() -> GenerationReceiptSigner:
+    configured = current_app.extensions.get("generation_receipt_signer")
+    if configured is not None:
+        return configured
+    secret = current_app.config.get("RULE_GENERATION_RECEIPT_SECRET")
+    if not isinstance(secret, str) or not secret.strip():
+        raise RuntimeError("dedicated generation receipt secret is not configured")
+    signer = GenerationReceiptSigner(secret)
+    current_app.extensions["generation_receipt_signer"] = signer
+    return signer
+
+
+def _rule_artifact_store():
+    configured = current_app.extensions.get("rule_artifact_store")
+    if configured is not None:
+        return configured
+    from minio import Minio
+
+    from app.runner.artifacts import ArtifactStore
+
+    store = ArtifactStore(
+        Minio(
+            current_app.config["MINIO_HOST"],
+            access_key=current_app.config["MINIO_USER"],
+            secret_key=current_app.config["MINIO_PASSWORD"],
+            secure=bool(current_app.config["MINIO_SECURE"]),
+        ),
+        bucket=current_app.config["MINIO_BUCKET"],
+        max_artifact_bytes=32 * 1024 * 1024,
+        max_rows=100_000,
+        memory_limit_bytes=256 * 1024 * 1024,
+        max_ttl_seconds=3600,
+    )
+    current_app.extensions["rule_artifact_store"] = store
+    return store
+
+
+def _publication_service() -> RulePublicationService:
+    configured = current_app.extensions.get("rule_publication_service")
+    if configured is not None:
+        return configured
+    test_runner = current_app.extensions.get("rule_validation_test_runner")
+    if test_runner is None:
+        try:
+            test_runner = ServerOwnedLogicalDryRunRunner(_rule_artifact_store())
+        except Exception as exc:
+            raise RuntimeError(
+                "trusted rule validation test runner is not configured"
+            ) from exc
+        current_app.extensions["rule_validation_test_runner"] = test_runner
+    service = RulePublicationService(
+        _repository(),
+        receipt_signer=_receipt_signer(),
+        compiler=LogicalRuleCompiler(),
+        test_runner=test_runner,
+    )
+    current_app.extensions["rule_publication_service"] = service
+    return service
+
+
+def _physical_publication_service() -> PhysicalPlanPublicationService:
+    configured = current_app.extensions.get("physical_plan_publication_service")
+    if configured is not None:
+        return configured
+    test_runner = current_app.extensions.get("rule_physical_test_runner")
+    if test_runner is None:
+        try:
+            from app.core.data_source.runtime import get_data_source_manager
+
+            test_runner = ServerOwnedPhysicalPreflightRunner(
+                _rule_artifact_store(),
+                datasource_manager=get_data_source_manager(),
+            )
+        except Exception as exc:
+            raise RuntimeError(
+                "trusted physical plan test runner is not configured"
+            ) from exc
+        current_app.extensions["rule_physical_test_runner"] = test_runner
+    service = PhysicalPlanPublicationService(_repository(), test_runner=test_runner)
+    current_app.extensions["physical_plan_publication_service"] = service
+    return service
+
+
+def _metadata_hash(value: Any) -> str:
+    return hashlib.sha256(
+        json.dumps(
+            value,
+            sort_keys=True,
+            separators=(",", ":"),
+            ensure_ascii=False,
+        ).encode("utf-8")
+    ).hexdigest()
+
+
+@bp.get("/capabilities")
+def capabilities():
+    readiness = _deployment_readiness()
+    if current_app.extensions.get("dataflow_deployment_service") is not None:
+        readiness = {
+            **readiness,
+            "ready": bool(readiness["gate_open"]),
+            "reasons": [
+                reason
+                for reason in readiness["reasons"]
+                if reason == "post_acceptance_activation_gate_closed"
+            ],
+        }
+    return jsonify(
+        success(
+            {
+                "natural_language_authoring": True,
+                "schema_constrained_candidates": True,
+                "production_line_preview": True,
+                "immutable_asset_versions": True,
+                "server_side_publishing": True,
+                "production_line_release": True,
+                "data_factory_activation": readiness["ready"],
+                "data_factory_readiness": readiness,
+            }
+        )
+    )
+
+
+@bp.post("/production-lines/draft-identity")
+def create_production_line_draft_identity():
+    """Persist a short-lived, single-use, actor-bound DataFlow reservation."""
+    try:
+        _closed_body(set())
+        result = _repository().reserve_dataflow_draft(actor_uid=g.current_user["id"])
+        db.session.commit()
+        return (
+            jsonify(
+                success(
+                    result,
+                    "生产线草稿身份已创建",
+                    code=201,
+                )
+            ),
+            201,
+        )
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return _bad_request("生产线草稿身份请求无效")
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("create DataFlow draft reservation failed")
+        return jsonify(failed("生产线草稿身份暂时不可用", code=503)), 503
+
+
+@bp.post("/validate")
+def validate_asset():
+    try:
+        body = _closed_body({"asset_type", "spec"})
+        asset_type = body.get("asset_type")
+        if asset_type not in _VALIDATORS:
+            raise ValueError("unsupported asset type")
+        validator, hasher = _VALIDATORS[asset_type]
+        normalized = validator(body.get("spec"))
+        return jsonify(
+            success(
+                {
+                    "asset_type": asset_type,
+                    "normalized": normalized,
+                    "spec_hash": hasher(normalized),
+                }
+            )
+        )
+    except (TypeError, ValueError):
+        return _bad_request("规则定义无效")
+
+
+def _authoring_agent() -> RuleAuthoringAgent:
+    configured = current_app.extensions.get("data_rule_authoring_agent")
+    if configured is not None:
+        return configured
+    api_key = current_app.config.get("LLM_API_KEY") or current_app.config.get(
+        "DEEPSEEK_API_KEY"
+    )
+    if not api_key:
+        raise RuntimeError("rule authoring model is not configured")
+    agent = RuleAuthoringAgent(model=OpenAICompatibleRuleModel())
+    current_app.extensions["data_rule_authoring_agent"] = agent
+    return agent
+
+
+@bp.post("/interpret")
+def interpret_rule():
+    try:
+        body = _closed_body({"source_text", "authoring_surface", "context"})
+        receipt_signer = _receipt_signer()
+        repository = _repository()
+        validation_context = repository.resolve_validation_context(
+            body.get("context", {})
+        )
+        result = _authoring_agent().interpret(
+            source_text=body.get("source_text"),
+            authoring_surface=body.get("authoring_surface"),
+            context=validation_context,
+        )
+        candidate = result.get("candidate")
+        if body.get("authoring_surface") == "data_standard" and (
+            not isinstance(candidate, dict)
+            or candidate.get("candidate_type") != "rule"
+            or not isinstance(candidate.get("rule_spec"), dict)
+        ):
+            raise ValueError("data_standard authoring must produce a rule candidate")
+        audit = repository.record_generation_run(
+            evidence=result,
+            created_by=g.current_user["id"],
+            validation_context=validation_context,
+        )
+        result = {
+            **result,
+            "generation_run_id": audit["id"],
+            "correlation_id": audit["correlation_id"],
+        }
+        if (
+            result.get("status") == "ready"
+            and isinstance(candidate, dict)
+            and candidate.get("candidate_type") == "rule"
+            and isinstance(candidate.get("rule_spec"), dict)
+        ):
+            claims = generation_receipt_claims(
+                generation_run_id=audit["id"],
+                actor_uid=g.current_user["id"],
+                source_text=result["source_text"],
+                candidate_hash=result["candidate_hash"],
+                rule_spec=candidate["rule_spec"],
+                model_hash=result.get("model_hash")
+                or _metadata_hash(
+                    {
+                        "provider": result.get("model_provider", "unknown"),
+                        "name": result.get("model_name", "unknown"),
+                    }
+                ),
+                prompt_hash=result.get("prompt_hash")
+                or _metadata_hash(result.get("prompt_version", "unknown")),
+                context_hash=result["context_hash"],
+                expires_at=datetime.now(UTC) + timedelta(minutes=10),
+            )
+            result["generation_receipt"] = receipt_signer.issue(claims)
+        db.session.commit()
+        return jsonify(success(result))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return _bad_request("自然语言规则描述无效")
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("AI 规则解析服务未配置", code=503)), 503
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("AI rule interpretation failed")
+        return jsonify(failed("AI 规则解析暂时不可用", code=503)), 503
+
+
+@bp.post("/rule-versions")
+def create_rule_version():
+    try:
+        body = _closed_body(
+            {
+                "rule_spec",
+                "source_text",
+                "generation_receipt",
+                "category",
+                "source_language",
+                "generated_kind",
+            }
+        )
+        # Enforce V2 at the HTTP boundary even when a test/different
+        # repository implementation is injected.
+        rule_spec = validate_rule_spec(body.get("rule_spec"))
+        result = _publication_service().create_draft(
+            rule_spec=rule_spec,
+            source_text=body.get("source_text"),
+            category=body.get("category", "general"),
+            source_language=body.get("source_language", "zh-CN"),
+            generated_kind=body.get("generated_kind", "rulespec"),
+            actor_uid=g.current_user["id"],
+            generation_receipt=body.get("generation_receipt"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "规则版本创建成功")), 201
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return _bad_request("规则版本定义无效")
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("create rule version failed")
+        return jsonify(failed("规则版本创建失败", code=500)), 500
+
+
+@bp.post("/rule-versions/<version_id>/publish")
+def publish_rule_version(version_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("publish request must not contain fields")
+        result = _publication_service().publish(version_id, g.current_user["id"])
+        db.session.commit()
+        return jsonify(success(result, "规则版本发布成功"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("规则版本无法发布", code=409)), 409
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("publish rule version failed")
+        return jsonify(failed("规则版本发布失败", code=500)), 500
+
+
+@bp.post("/rule-versions/<version_id>/validate")
+def validate_rule_version(version_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("validate request must not contain fields")
+        result = _publication_service().validate(version_id, g.current_user["id"])
+        db.session.commit()
+        return jsonify(success(result, "规则版本编译验证成功"))
+    except RuleValidationRejected:
+        db.session.commit()
+        return jsonify(failed("规则版本编译验证失败", code=409)), 409
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("规则版本编译验证失败", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("规则验证服务未配置", code=503)), 503
+
+
+@bp.post("/rule-versions/<version_id>/test")
+def test_rule_version(version_id: str):
+    try:
+        body = _closed_body({"plan_id"})
+        result = _publication_service().test(
+            version_id,
+            g.current_user["id"],
+            plan_id=body.get("plan_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "规则版本样本测试成功"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("规则版本样本测试失败", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("规则测试服务未配置", code=503)), 503
+
+
+@bp.get("/rule-versions/<version_id>/evidence")
+def rule_version_evidence(version_id: str):
+    try:
+        return jsonify(
+            success(
+                _repository().get_asset_evidence(
+                    asset_type="rule", version_id=version_id
+                )
+            )
+        )
+    except (TypeError, ValueError):
+        return jsonify(failed("规则证据不存在", code=404)), 404
+    except Exception:
+        current_app.logger.exception("load rule evidence failed")
+        return jsonify(failed("规则证据暂时不可用", code=503)), 503
+
+
+@bp.get("/catalog/assets/<asset_type>/<version_id>/evidence")
+def catalog_asset_evidence(asset_type: str, version_id: str):
+    try:
+        return jsonify(
+            success(
+                _repository().get_asset_evidence(
+                    asset_type=asset_type,
+                    version_id=version_id,
+                )
+            )
+        )
+    except (TypeError, ValueError):
+        return jsonify(failed("资产证据不存在", code=404)), 404
+    except Exception:
+        current_app.logger.exception("load catalog asset evidence failed")
+        return jsonify(failed("资产证据暂时不可用", code=503)), 503
+
+
+def _catalog_schema_context():
+    raw_inputs = request.args.get("input_schema_refs")
+    output = request.args.get("output_schema_ref")
+    if raw_inputs is None and output is None:
+        return None, None
+    if raw_inputs is None or output is None:
+        raise ValueError("catalog schema context is incomplete")
+    inputs = json.loads(raw_inputs)
+    if not isinstance(inputs, list):
+        raise ValueError("catalog input_schema_refs must be an array")
+    return inputs, output
+
+
+@bp.get("/catalog/assets/<asset_type>/<version_id>")
+def catalog_asset(asset_type: str, version_id: str):
+    try:
+        if set(request.args) - {
+            "input_schema_refs",
+            "output_schema_ref",
+        }:
+            raise ValueError("catalog asset query contains unsupported fields")
+        inputs, output = _catalog_schema_context()
+        result = _repository().get_published_asset(
+            asset_type=asset_type,
+            version_id=version_id,
+            input_schema_refs=inputs,
+            output_schema_ref=output,
+            schema_resolver=_schema_resolver() if inputs is not None else None,
+        )
+        if inputs is not None:
+            # SchemaResolver is a read-through snapshot boundary. Persist the
+            # IDs returned to this response before making them observable.
+            db.session.commit()
+        return jsonify(success(result))
+    except (TypeError, ValueError, json.JSONDecodeError):
+        db.session.rollback()
+        return jsonify(failed("已发布资产不存在或上下文无效", code=404)), 404
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("load exact catalog asset failed")
+        return jsonify(failed("规则目录暂时不可用", code=503)), 503
+
+
+@bp.get("/catalog")
+@bp.get("/catalog/rule-versions")
+def published_rule_catalog():
+    try:
+        legacy_rule_alias = request.path.endswith("/rule-versions")
+        allowed = {
+            "query",
+            "limit",
+            "offset",
+            "input_schema_refs",
+            "output_schema_ref",
+        }
+        if not legacy_rule_alias:
+            allowed.add("asset_type")
+        if set(request.args) - allowed:
+            raise ValueError("catalog query contains unsupported fields")
+        query = request.args.get("query", "")
+        limit = int(request.args.get("limit", "50"))
+        offset = int(request.args.get("offset", "0"))
+        asset_type = (
+            "rule" if legacy_rule_alias else request.args.get("asset_type") or None
+        )
+        if asset_type not in {None, "rule", "standard"}:
+            raise ValueError("catalog asset_type is invalid")
+        if len(query) > 200 or limit < 1 or limit > 100:
+            raise ValueError("catalog bounds are invalid")
+        if offset < 0 or offset > 1_000_000:
+            raise ValueError("catalog offset is invalid")
+        inputs, output = _catalog_schema_context()
+        catalog_args = {
+            "query": query,
+            "asset_type": asset_type,
+            "limit": limit,
+            "offset": offset,
+        }
+        if inputs is not None:
+            catalog_args.update(
+                {
+                    "input_schema_refs": inputs,
+                    "output_schema_ref": output,
+                    "schema_resolver": _schema_resolver(),
+                }
+            )
+        result = _repository().search_published_assets(**catalog_args)
+        if inputs is not None:
+            db.session.commit()
+        return jsonify(success(result))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return _bad_request("规则目录查询无效")
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("load rule catalog failed")
+        return jsonify(failed("规则目录暂时不可用", code=503)), 503
+
+
+@bp.post("/execution-plans/<plan_id>/validate")
+def validate_physical_plan(plan_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("validate request must not contain fields")
+        result = _physical_publication_service().validate(plan_id, g.current_user["id"])
+        db.session.commit()
+        return jsonify(success(result, "物理执行计划编译证据已确认"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("物理执行计划验证失败", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("物理计划验证服务未配置", code=503)), 503
+
+
+@bp.post("/execution-plans/<plan_id>/test")
+def test_physical_plan(plan_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("test request must not contain fields")
+        result = _physical_publication_service().test(plan_id, g.current_user["id"])
+        db.session.commit()
+        return jsonify(success(result, "物理执行计划样本测试成功"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("物理执行计划样本测试失败", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("物理计划测试服务未配置", code=503)), 503
+
+
+@bp.post("/execution-plans/<plan_id>/publish")
+def publish_physical_plan(plan_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("publish request must not contain fields")
+        result = _physical_publication_service().publish(plan_id, g.current_user["id"])
+        db.session.commit()
+        return jsonify(success(result, "物理执行计划发布成功"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("物理执行计划无法发布", code=409)), 409
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("物理计划发布服务未配置", code=503)), 503
+
+
+@bp.post("/standard-versions")
+def create_standard_version():
+    try:
+        body = _closed_body({"standard_spec", "source_text"})
+        result = _repository().create_standard_version(
+            standard_spec=body.get("standard_spec"),
+            source_text=body.get("source_text"),
+            created_by=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "数据标准版本创建成功")), 201
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return _bad_request("数据标准版本定义无效")
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("create standard version failed")
+        return jsonify(failed("数据标准版本创建失败", code=500)), 500
+
+
+@bp.post("/standard-versions/<version_id>/publish")
+def publish_standard_version(version_id: str):
+    try:
+        if request.get_data(cache=True) and request.get_json(silent=True) not in (
+            None,
+            {},
+        ):
+            raise ValueError("publish request must not contain fields")
+        result = _repository().publish_standard_version(
+            version_id=version_id,
+            published_by=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "数据标准版本发布成功"))
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("数据标准版本无法发布", code=409)), 409
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("publish standard version failed")
+        return jsonify(failed("数据标准版本发布失败", code=500)), 500
+
+
+@bp.post("/production-lines/resolve")
+def resolve_production_line_preview():
+    try:
+        body = _body()
+        package = resolve_production_line(
+            body.get("dataflow_spec"),
+            body.get("standard_versions"),
+            body.get("rule_versions"),
+            component_binding_ids=body.get("component_binding_ids"),
+        )
+        return jsonify(
+            success(
+                {
+                    "preview": True,
+                    "release_ready": False,
+                    "package": package,
+                }
+            )
+        )
+    except (TypeError, ValueError):
+        return _bad_request("数据生产线定义无效")
+
+
+@bp.post("/production-lines/<dataflow_uid>/release")
+def release_production_line(dataflow_uid: str):
+    try:
+        body = _closed_body(
+            {
+                "dataflow_spec",
+                "source_text",
+            }
+        )
+        result = _release_service().release(
+            dataflow_uid=dataflow_uid,
+            dataflow_spec=body.get("dataflow_spec"),
+            source_text=body.get("source_text"),
+            created_by=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "数据生产线发布成功")), 201
+    except (TypeError, ValueError):
+        db.session.rollback()
+        return jsonify(failed("数据生产线无法发布", code=409)), 409
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("release production line failed")
+        return jsonify(failed("数据生产线发布失败", code=500)), 500
+
+
+@bp.get("/deployments")
+def list_dataflow_deployments():
+    try:
+        if set(request.args) - {"environment"}:
+            raise ValueError("deployment query contains unsupported fields")
+        return jsonify(
+            success(
+                {
+                    "items": _deployment_service().list(
+                        environment=request.args.get("environment") or None
+                    ),
+                    "readiness": _deployment_readiness(),
+                }
+            )
+        )
+    except (TypeError, ValueError):
+        return _bad_request("数据工厂投产查询无效")
+    except RuntimeError:
+        return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
+
+
+@bp.get("/deployments/candidates")
+def list_dataflow_deployment_candidates():
+    try:
+        if set(request.args) != {"environment"}:
+            raise ValueError("candidate environment is required")
+        return jsonify(
+            success(
+                {
+                    "items": _deployment_service().candidates(
+                        environment=request.args["environment"]
+                    )
+                }
+            )
+        )
+    except (TypeError, ValueError):
+        return _bad_request("可入厂生产线查询无效")
+    except RuntimeError:
+        return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
+
+
+@bp.post("/deployments")
+def create_dataflow_deployment():
+    try:
+        body = _closed_body(
+            {
+                "dataflow_version_id",
+                "environment",
+                "binding_snapshot",
+                "schedule_plan",
+                "reason",
+                "idempotency_key",
+                "correlation_id",
+            }
+        )
+        result = _deployment_service().create(
+            body.get("dataflow_version_id"),
+            binding_snapshot=body.get("binding_snapshot"),
+            environment=body.get("environment"),
+            schedule_plan=body.get("schedule_plan"),
+            actor_uid=g.current_user["id"],
+            reason=body.get("reason"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "数据生产线已进入数据工厂")), 201
+    except (TypeError, ValueError) as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "数据工厂投产定义无效")
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("数据工厂投产服务未配置", code=503)), 503
+    except Exception:
+        db.session.rollback()
+        current_app.logger.exception("create dataflow deployment failed")
+        return jsonify(failed("数据工厂投产创建失败", code=500)), 500
+
+
+def _deployment_action_body(extra: set[str] | None = None):
+    return _closed_body(
+        {
+            "reason",
+            "idempotency_key",
+            "correlation_id",
+            *(extra or set()),
+        }
+    )
+
+
+def _deployment_conflict(exc: Exception, message: str):
+    code = getattr(exc, "code", "terminal_conflict")
+    disposition = getattr(exc, "idempotency_key_disposition", "rotate")
+    error = {
+        "code": code,
+        "idempotency_key_disposition": disposition,
+        "reconcile_required": code == "operation_unknown",
+    }
+    blocking_operation = getattr(exc, "blocking_operation", None)
+    if blocking_operation is not None:
+        error["blocking_operation"] = blocking_operation
+    return (
+        jsonify(
+            failed(
+                message,
+                code=409,
+                error=error,
+            )
+        ),
+        409,
+    )
+
+
+@bp.post("/deployments/<deployment_id>/deploy-disabled")
+def deploy_dataflow_disabled(deployment_id: str):
+    try:
+        body = _deployment_action_body()
+        result = _deployment_service().deploy_disabled(
+            deployment_id,
+            g.current_user["id"],
+            reason=body.get("reason", "deploy disabled"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "生产线已禁用部署"))
+    except DeploymentOperationError as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "生产线禁用部署状态冲突")
+    except (TypeError, ValueError) as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "生产线无法禁用部署")
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("调度引擎部署结果异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/canary")
+def run_dataflow_canary(deployment_id: str):
+    try:
+        body = _deployment_action_body({"inputs"})
+        result = _deployment_service().run_canary(
+            deployment_id,
+            body.get("inputs", {}),
+            g.current_user["id"],
+            reason=body.get("reason", "trial production"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "试生产已完成"))
+    except DeploymentOperationError as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "试生产操作状态冲突")
+    except (TypeError, ValueError) as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "试生产请求无效")
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("试生产执行结果异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/activate")
+def activate_dataflow_deployment(deployment_id: str):
+    if not _activation_gate_ready():
+        return jsonify(failed("数据工厂激活门禁尚未开放", code=503)), 503
+    try:
+        body = _deployment_action_body({"evidence_id"})
+        result = _deployment_service().activate(
+            deployment_id,
+            body.get("evidence_id"),
+            g.current_user["id"],
+            reason=body.get("reason", "mass production activation"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "生产线已转入批量生产"))
+    except DeploymentOperationError as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "生产线激活状态冲突")
+    except (TypeError, ValueError) as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "生产线无法激活")
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("生产线激活结果异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/execute")
+def execute_active_dataflow_deployment(deployment_id: str):
+    try:
+        body = _deployment_action_body({"inputs"})
+        result = _deployment_service().execute_active(
+            deployment_id,
+            body.get("inputs", {}),
+            g.current_user["id"],
+            reason=body.get("reason", "manual production execution"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "生产数据处理已完成"))
+    except DeploymentOperationError as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "生产执行操作状态冲突")
+    except (TypeError, ValueError) as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "生产执行请求无效")
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("生产数据处理执行异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/rollback")
+def rollback_dataflow_deployment(deployment_id: str):
+    try:
+        body = _deployment_action_body()
+        result = _deployment_service().rollback(
+            deployment_id,
+            g.current_user["id"],
+            reason=body.get("reason", "restore previous production line"),
+            idempotency_key=body.get("idempotency_key"),
+            correlation_id=body.get("correlation_id"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "已恢复上一条稳定生产线"))
+    except DeploymentOperationError as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "生产线回滚状态冲突")
+    except (TypeError, ValueError) as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "生产线无法回滚")
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("生产线回滚结果异常", code=503)), 503
+
+
+def _reconcile_dataflow_deployment(deployment_id: str, action: str):
+    try:
+        body = _closed_body({"idempotency_key"})
+        result = _deployment_service().reconcile(
+            deployment_id,
+            action,
+            g.current_user["id"],
+            idempotency_key=body.get("idempotency_key"),
+        )
+        db.session.commit()
+        return jsonify(success(result, "投产未知结果已完成对账"))
+    except PermissionError:
+        db.session.rollback()
+        return jsonify(failed("权限不足", code=403)), 403
+    except DeploymentOperationError as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "投产对账状态冲突")
+    except (TypeError, ValueError) as exc:
+        db.session.rollback()
+        return _deployment_conflict(exc, "投产未知结果无法对账")
+    except RuntimeError:
+        db.session.rollback()
+        return jsonify(failed("投产对账服务异常", code=503)), 503
+
+
+@bp.post("/deployments/<deployment_id>/reconcile-deploy")
+def reconcile_dataflow_deploy(deployment_id: str):
+    return _reconcile_dataflow_deployment(deployment_id, "deploy_disabled")
+
+
+@bp.post("/deployments/<deployment_id>/reconcile-activate")
+def reconcile_dataflow_activate(deployment_id: str):
+    return _reconcile_dataflow_deployment(deployment_id, "activate")
+
+
+@bp.post("/deployments/<deployment_id>/reconcile-rollback")
+def reconcile_dataflow_rollback(deployment_id: str):
+    return _reconcile_dataflow_deployment(deployment_id, "rollback")
+
+
+@bp.post("/deployments/<deployment_id>/reconcile-execute")
+def reconcile_dataflow_execute(deployment_id: str):
+    return _reconcile_dataflow_deployment(deployment_id, "execute_active")

+ 1 - 0
app/api/data_service/__init__.py

@@ -4,3 +4,4 @@ bp = Blueprint("data_service", __name__)
 
 # 导入 routes 模块以注册路由(副作用导入)
 from app.api.data_service import routes  # noqa: E402, F401, I001  # pyright: ignore[reportUnusedImport]
+from app.api.data_service import product_governance_routes  # noqa: E402, F401, I001  # pyright: ignore[reportUnusedImport]

+ 302 - 0
app/api/data_service/product_governance_routes.py

@@ -0,0 +1,302 @@
+"""Governed data-product, application, contract, certificate and feedback API."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.data_service import bp
+from app.core.data_service.product_governance import ProductGovernanceService
+from app.core.data_service.product_governance_repository import (
+    SqlAlchemyProductGovernanceRepository,
+    WorkCenterProductApprovalGateway,
+)
+from app.core.system.permissions import (
+    DATA_PRODUCTS_MANAGE,
+    DATA_PRODUCTS_OPERATE,
+    DATA_PRODUCTS_READ,
+    permissions_for_roles,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return ProductGovernanceService(
+        SqlAlchemyProductGovernanceRepository(db.session),
+        approval_gateway=WorkCenterProductApprovalGateway(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _expected_version():
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(raw)
+
+
+def _etag(response, version):
+    response.headers["ETag"] = f'"{int(version)}"'
+    return response
+
+
+def _error(exc):
+    db.session.rollback()
+    if "If-Match" in str(exc):
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, PermissionError):
+        status = 403
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/governance/products", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def list_governed_products():
+    filters = {
+        key: request.args.get(key)
+        for key in ("status", "product_type", "business_domain_uid", "owner_uid")
+        if request.args.get(key)
+    }
+    return jsonify(success(_service().list_products(**filters)))
+
+
+@bp.route("/governance/products", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def register_governed_product():
+    try:
+        result = _service().register_product(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "数据产品治理登记已创建", code=201)), 1), 201
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def get_governed_product(product_uid):
+    try:
+        result = _service().product_detail(product_uid)
+        return _etag(jsonify(success(result)), result["current_version"])
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/transition", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def transition_governed_product(product_uid):
+    try:
+        result = _service().transition_product(
+            product_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品生命周期已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def list_product_applications():
+    permissions = permissions_for_roles(g.current_user.get("roles", []))
+    return jsonify(
+        success(
+            _service().list_applications(
+                status=request.args.get("status"),
+                requester_uid=g.current_user["id"],
+                can_manage=DATA_PRODUCTS_MANAGE in permissions,
+            )
+        )
+    )
+
+
+@bp.route("/governance/applications", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_application():
+    try:
+        result = _service().create_application(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "产品申请草稿已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/submit", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def submit_product_application(application_uid):
+    try:
+        result = _service().submit_application(
+            application_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品申请已进入统一审批")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/reconcile", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def reconcile_product_application(application_uid):
+    try:
+        result = _service().reconcile_application(
+            application_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "审批结果已同步")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/applications/<application_uid>/fulfill", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_MANAGE)
+def fulfill_product_application(application_uid):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _service().fulfill_application(
+            application_uid,
+            body.get("product_uid"),
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品申请已履约")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/contracts", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_contract(product_uid):
+    try:
+        result = _service().create_contract(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同草稿已创建", code=201)), 1), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/compatibility", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def check_product_contract_compatibility(contract_uid):
+    try:
+        return jsonify(
+            success(
+                _service().check_contract_compatibility(
+                    contract_uid, request.get_json(silent=True) or {}
+                )
+            )
+        )
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/revisions", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def revise_product_contract(contract_uid):
+    try:
+        result = _service().revise_contract(
+            contract_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同版本已创建")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/publish", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def publish_product_contract(contract_uid):
+    try:
+        result = _service().publish_contract(
+            contract_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同已发布")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/contracts/<contract_uid>/terminate", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def terminate_product_contract(contract_uid):
+    try:
+        result = _service().terminate_contract(
+            contract_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "数据合同已终止")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/certificates", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def issue_product_certificate(product_uid):
+    try:
+        result = _service().generate_certificate(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "产品合格证已生成", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/products/<product_uid>/feedback", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def create_product_feedback(product_uid):
+    try:
+        result = _service().create_feedback(
+            product_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "产品反馈已创建", code=201)), 1), 201
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/feedback/<feedback_uid>/transition", methods=["POST"])
+@require_permissions(DATA_PRODUCTS_OPERATE)
+def transition_product_feedback(feedback_uid):
+    try:
+        result = _service().transition_feedback(
+            feedback_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "反馈改进状态已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/governance/dashboard", methods=["GET"])
+@require_permissions(DATA_PRODUCTS_READ)
+def product_governance_dashboard():
+    return jsonify(success(_service().dashboard()))

+ 3 - 0
app/api/data_source/__init__.py

@@ -1,5 +1,8 @@
+# ruff: noqa: E402, F401, I001
+
 from flask import Blueprint
 
 bp = Blueprint("data_source", __name__)
 
+from app.api.data_source import edge_routes  # noqa: E402, F401
 from app.api.data_source import routes  # noqa: E402, F401

+ 299 - 0
app/api/data_source/edge_routes.py

@@ -0,0 +1,299 @@
+"""Bounded HTTP boundary for human and machine edge-gateway operations."""
+
+from __future__ import annotations
+
+import hmac
+import ipaddress
+import logging
+import re
+from datetime import UTC, datetime
+from urllib.parse import unquote
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes
+from flask import current_app, g, jsonify, request
+
+from app.api.data_source import bp
+from app.core.edge_gateway.contracts import EdgeContractError, preflight_json
+from app.core.edge_gateway.service import (
+    EdgeGatewayAuthenticationError,
+    EdgeGatewayError,
+    EdgeGatewayPayloadTooLargeError,
+    EdgeGatewayService,
+    EdgeGatewayValidationError,
+)
+from app.models.result import failed, success
+
+logger = logging.getLogger(__name__)
+MAX_EDGE_REQUEST_BYTES = 262_144
+MAX_EDGE_REQUEST_NODES = 5_000
+MAX_EDGE_RESPONSE_BYTES = 1_048_576
+
+
+def _service() -> EdgeGatewayService:
+    from app import db
+    from app.core.edge_gateway.repository import EdgeGatewayRepository
+
+    return EdgeGatewayService(
+        EdgeGatewayRepository(db.session),
+        signing_private_key=current_app.config.get(
+            "EDGE_GATEWAY_SIGNING_PRIVATE_KEY"
+        ),
+        signing_private_key_file=current_app.config.get(
+            "EDGE_GATEWAY_SIGNING_PRIVATE_KEY_FILE"
+        ),
+        signing_public_key=current_app.config.get(
+            "EDGE_GATEWAY_SIGNING_PUBLIC_KEY"
+        ),
+        signing_key_id=current_app.config.get("EDGE_GATEWAY_SIGNING_KEY_ID"),
+        production=(
+            str(current_app.config.get("FLASK_ENV", "")).lower() == "production"
+        ),
+    )
+
+
+def _actor_uid():
+    identity = getattr(g, "current_user", {}) or {}
+    return identity.get("id") or identity.get("sub")
+
+
+def _payload() -> dict:
+    if request.content_length is not None and request.content_length > MAX_EDGE_REQUEST_BYTES:
+        raise EdgeGatewayPayloadTooLargeError("edge request is too large")
+    raw = request.get_data(cache=True)
+    if len(raw) > MAX_EDGE_REQUEST_BYTES:
+        raise EdgeGatewayPayloadTooLargeError("edge request is too large")
+    value = request.get_json(silent=True)
+    if value is None and not raw:
+        return {}
+    if not isinstance(value, dict):
+        raise EdgeGatewayValidationError("edge request body is invalid")
+    try:
+        preflight_json(
+            value, max_depth=32, max_nodes=MAX_EDGE_REQUEST_NODES,
+            max_string_bytes=131_072, max_total_bytes=MAX_EDGE_REQUEST_BYTES,
+        )
+    except EdgeContractError as exc:
+        raise EdgeGatewayPayloadTooLargeError("edge request exceeds safe limits") from exc
+    return value
+
+
+def _tls_certificate_sha256() -> str:
+    configured = current_app.config.get("EDGE_MTLS_TRUSTED_PROXY_IPS", ())
+    if isinstance(configured, str):
+        configured = tuple(
+            item.strip() for item in configured.split(",") if item.strip()
+        )
+    if not isinstance(configured, (tuple, list, set, frozenset)) or not configured:
+        raise EdgeGatewayAuthenticationError(
+            "trusted mTLS proxy is not configured"
+        )
+    try:
+        trusted = {str(ipaddress.ip_address(item)) for item in configured}
+        remote = str(ipaddress.ip_address(request.remote_addr or ""))
+    except ValueError as exc:
+        raise EdgeGatewayAuthenticationError(
+            "request did not originate from a trusted mTLS proxy"
+        ) from exc
+    if remote not in trusted:
+        raise EdgeGatewayAuthenticationError(
+            "request did not originate from a trusted mTLS proxy"
+        )
+    if request.headers.get("X-DataOps-Edge-Client-Verify") != "SUCCESS":
+        raise EdgeGatewayAuthenticationError(
+            "verified TLS client certificate is required"
+        )
+    escaped_certificate = request.headers.get("X-DataOps-Edge-Client-Cert", "")
+    if not escaped_certificate or len(escaped_certificate.encode()) > 32_768:
+        raise EdgeGatewayAuthenticationError(
+            "verified TLS client certificate is required"
+        )
+    try:
+        certificate = x509.load_pem_x509_certificate(
+            unquote(escaped_certificate).encode()
+        )
+    except ValueError as exc:
+        raise EdgeGatewayAuthenticationError(
+            "verified TLS client certificate is invalid"
+        ) from exc
+    now = datetime.now(UTC)
+    if (
+        certificate.not_valid_before_utc > now
+        or certificate.not_valid_after_utc <= now
+    ):
+        raise EdgeGatewayAuthenticationError(
+            "verified TLS client certificate is not currently valid"
+        )
+    proof = certificate.fingerprint(hashes.SHA256()).hex()
+    header = request.headers.get("X-Edge-Certificate-SHA256")
+    if header and (
+        not re.fullmatch(r"[0-9a-f]{64}", header)
+        or not hmac.compare_digest(header, proof)
+    ):
+        raise EdgeGatewayAuthenticationError(
+            "TLS client certificate proof does not match"
+        )
+    return proof
+
+
+def _machine_auth(gateway_id: str, payload: dict) -> dict:
+    return {
+        "credential": request.headers.get("X-Edge-Credential", ""),
+        "certificate_sha256": _tls_certificate_sha256(),
+        "gateway_id": gateway_id,
+        "environment": payload.get("environment"),
+        "network_zone": payload.get("network_zone"),
+        "generation": payload.get("generation"),
+    }
+
+
+def _respond(call, created: bool = False):
+    try:
+        result = call()
+        response = jsonify(success(result))
+        if len(response.get_data()) > MAX_EDGE_RESPONSE_BYTES:
+            response = jsonify(failed(
+                "边缘网关响应超过安全上限", code=500,
+                error={"code": "EDGE_GATEWAY_RESPONSE_LIMIT"},
+            ))
+            response.headers["Cache-Control"] = "no-store"
+            return response, 500
+        response.headers["Cache-Control"] = "no-store"
+        return response, 201 if created else 200
+    except EdgeGatewayError as exc:
+        logger.warning("edge gateway request rejected: code=%s", exc.code)
+        response = jsonify(failed("边缘网关请求被拒绝", code=exc.status_code, error={"code": exc.code}))
+        response.headers["Cache-Control"] = "no-store"
+        return response, exc.status_code
+    except Exception:
+        logger.exception("edge gateway request failed")
+        response = jsonify(failed("边缘网关操作失败", code=500, error={"code": "EDGE_GATEWAY_INTERNAL"}))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 500
+
+
+@bp.errorhandler(EdgeGatewayError)
+def edge_gateway_error(error):
+    response = jsonify(failed("边缘网关请求被拒绝", code=error.status_code, error={"code": error.code}))
+    response.headers["Cache-Control"] = "no-store"
+    return response, error.status_code
+
+
+@bp.route("/edge/enrollments", methods=["POST"])
+def edge_create_enrollment():
+    payload = _payload()
+    return _respond(lambda: _service().create_enrollment(
+        gateway_name=payload.get("gateway_name"), environment=payload.get("environment"),
+        network_zone=payload.get("network_zone"), policy_digest=payload.get("policy_digest"),
+        allowed_control_hosts=payload.get("allowed_control_hosts"),
+        allowed_proxy_hosts=payload.get("allowed_proxy_hosts", []),
+        expected_certificate_sha256=payload.get("expected_certificate_sha256"),
+        ttl_seconds=payload.get("ttl_seconds", 600), actor_uid=_actor_uid(),
+    ), created=True)
+
+
+@bp.route("/edge/register", methods=["POST"])
+def edge_register():
+    payload = _payload()
+    return _respond(lambda: _service().register(
+        enrollment_token=request.headers.get("X-Edge-Enrollment", ""),
+        certificate_sha256=_tls_certificate_sha256(),
+        gateway_id=payload.get("gateway_id"), environment=payload.get("environment"),
+        network_zone=payload.get("network_zone"), policy_digest=payload.get("policy_digest"),
+        allowed_control_hosts=payload.get("allowed_control_hosts"),
+        allowed_proxy_hosts=payload.get("allowed_proxy_hosts", []), version=payload.get("version"),
+    ), created=True)
+
+
+@bp.route("/edge/gateways", methods=["GET"])
+def edge_list_gateways():
+    return _respond(lambda: {"gateways": _service().list_gateways(
+        limit=request.args.get("limit", 50), offset=request.args.get("offset", 0),
+    )})
+
+
+@bp.route("/edge/gateways/<gateway_id>/heartbeat", methods=["POST"])
+def edge_heartbeat(gateway_id):
+    payload = _payload()
+    return _respond(lambda: _service().heartbeat(
+        **_machine_auth(gateway_id, payload), version=payload.get("version"),
+        safe_summary=payload.get("safe_summary", {}),
+    ))
+
+
+@bp.route("/edge/gateways/<gateway_id>/rotate", methods=["POST"])
+def edge_rotate(gateway_id):
+    payload = _payload()
+    return _respond(lambda: _service().rotate(
+        gateway_id, certificate_sha256=payload.get("certificate_sha256"), actor_uid=_actor_uid(),
+        request_id=payload.get("request_id"),
+    ))
+
+
+@bp.route("/edge/gateways/<gateway_id>/revoke", methods=["POST"])
+def edge_revoke(gateway_id):
+    return _respond(lambda: {"revoked": _service().revoke(gateway_id, actor_uid=_actor_uid())})
+
+
+@bp.route("/edge/tasks", methods=["POST"])
+def edge_issue_task():
+    return _respond(lambda: _service().issue_task(_payload(), actor_uid=_actor_uid()), created=True)
+
+
+@bp.route("/edge/tasks/<task_id>/cancel", methods=["POST"])
+def edge_cancel_task(task_id):
+    return _respond(lambda: {"cancel_requested": _service().cancel_task(task_id, actor_uid=_actor_uid())})
+
+
+@bp.route("/edge/gateways/<gateway_id>/tasks/pull", methods=["POST"])
+def edge_pull_task(gateway_id):
+    payload = _payload()
+    return _respond(lambda: _service().pull_task(**_machine_auth(gateway_id, payload)))
+
+
+@bp.route("/edge/gateways/<gateway_id>/tasks/<task_id>/outcome", methods=["POST"])
+def edge_task_outcome(gateway_id, task_id):
+    payload = _payload()
+    return _respond(lambda: _service().task_outcome(
+        task_id, outcome=payload.get("outcome"), lease_token=payload.get("lease_token"),
+        safe_summary=payload.get("safe_summary", {}), **_machine_auth(gateway_id, payload),
+    ))
+
+
+@bp.route("/edge/gateways/<gateway_id>/events", methods=["POST"])
+def edge_accept_event(gateway_id):
+    payload = _payload()
+    return _respond(lambda: _service().accept_event(
+        event=payload.get("event"), lease_token=payload.get("lease_token"),
+        **_machine_auth(gateway_id, payload),
+    ))
+
+
+@bp.route("/edge/gateways/<gateway_id>/reconcile", methods=["POST"])
+def edge_reconcile(gateway_id):
+    payload = _payload()
+    return _respond(lambda: _service().reconcile(
+        limit=payload.get("limit", 50),
+        cancel_cursor=payload.get("cancel_cursor"),
+        release_cursor=payload.get("release_cursor"),
+        **_machine_auth(gateway_id, payload),
+    ))
+
+
+@bp.route("/edge/gateways/<gateway_id>/releases", methods=["POST"])
+def edge_offer_release(gateway_id):
+    payload = _payload()
+    return _respond(lambda: _service().offer_release(
+        gateway_id, version=payload.get("version"), artifact_digest=payload.get("artifact_digest"),
+        artifact_name=payload.get("artifact_name"), deadline_at=payload.get("deadline_at"),
+        rollback_version=payload.get("rollback_version"), request_id=payload.get("request_id"), actor_uid=_actor_uid(),
+    ), created=True)
+
+
+@bp.route("/edge/gateways/<gateway_id>/releases/ack", methods=["POST"])
+def edge_ack_release(gateway_id):
+    payload = _payload()
+    return _respond(lambda: _service().acknowledge_release(
+        release_id=payload.get("release_id"), outcome=payload.get("outcome"),
+        safe_summary=payload.get("safe_summary", {}), **_machine_auth(gateway_id, payload),
+    ))

+ 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)

+ 1 - 1
app/api/knowledge_base/__init__.py

@@ -2,4 +2,4 @@ from flask import Blueprint
 
 bp = Blueprint("knowledge_base", __name__)
 
-from app.api.knowledge_base import routes  # noqa: E402,F401
+from app.api.knowledge_base import agent_governance_routes, routes  # noqa: E402, F401

+ 377 - 0
app/api/knowledge_base/agent_governance_routes.py

@@ -0,0 +1,377 @@
+"""Governed Agent registry, authorization decisions and replay APIs."""
+
+from __future__ import annotations
+
+import hashlib
+import uuid
+
+from flask import current_app, g, jsonify, request
+
+from app import db
+from app.api.knowledge_base import bp
+from app.config.config import is_placeholder_env_value
+from app.core.llm.agent_governance import AgentGovernanceService
+from app.core.llm.agent_governance_repository import (
+    SqlAlchemyAgentGovernanceRepository,
+    WorkCenterAgentApprovalGateway,
+)
+from app.core.llm.runtime_governance_repository import (
+    SqlAlchemyRuntimeGovernanceRepository,
+)
+from app.core.llm.runtime_server import (
+    RuntimeServerContext,
+    RuntimeServerError,
+    ServerGovernedInvocationService,
+)
+from app.core.system.permissions import (
+    AGENTS_MANAGE,
+    AGENTS_OPERATE,
+    AGENTS_READ,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+class AgentGovernanceUnavailable(RuntimeError):
+    """Raised when production lacks a dedicated credential signing secret."""
+
+
+def _effective_credential_secret() -> tuple[str, bool]:
+    dedicated = str(current_app.config.get("AGENT_CREDENTIAL_SECRET") or "").strip()
+    if len(dedicated.encode()) >= 32 and not is_placeholder_env_value(dedicated):
+        return dedicated, True
+    fallback = hashlib.sha256(
+        ("dataops-wp09-local-agent:" + str(current_app.config.get("SECRET_KEY") or "")).encode()
+    ).hexdigest()
+    return fallback, False
+
+
+def _require_credential_secret() -> None:
+    _secret, dedicated_ready = _effective_credential_secret()
+    if (
+        not dedicated_ready
+        and not current_app.config.get("TESTING")
+        and str(current_app.config.get("FLASK_ENV") or "").lower() == "production"
+    ):
+        raise AgentGovernanceUnavailable(
+            "dedicated Agent credential secret is required in production"
+        )
+
+
+def _service():
+    secret, _dedicated_ready = _effective_credential_secret()
+    return AgentGovernanceService(
+        SqlAlchemyAgentGovernanceRepository(db.session),
+        approval_gateway=WorkCenterAgentApprovalGateway(db.session),
+        credential_secret=secret,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _runtime_service():
+    return ServerGovernedInvocationService(SqlAlchemyRuntimeGovernanceRepository(db.session))
+
+
+def _expected_version() -> int:
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(raw)
+
+
+def _etag(response, version):
+    response.headers["ETag"] = f'"{int(version)}"'
+    return response
+
+
+def _no_store(response):
+    response.headers["Cache-Control"] = "no-store"
+    return response
+
+
+def _error(exc):
+    db.session.rollback()
+    if "If-Match" in str(exc):
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, (PermissionError, AgentGovernanceUnavailable)):
+        status = 403 if isinstance(exc, PermissionError) else 503
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/agents", methods=["GET"])
+@require_permissions(AGENTS_READ)
+def list_governed_agents():
+    filters = {
+        key: request.args.get(key)
+        for key in ("status", "autonomy_level", "owner_uid")
+        if request.args.get(key)
+    }
+    return jsonify(success(_service().list_agents(**filters)))
+
+
+@bp.route("/agents", methods=["POST"])
+@require_permissions(AGENTS_MANAGE)
+def register_governed_agent():
+    try:
+        result = _service().register_agent(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "Agent 治理登记已创建", code=201)), 1), 201
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>", methods=["GET"])
+@require_permissions(AGENTS_READ)
+def get_governed_agent(agent_uid):
+    try:
+        result = _service().agent_detail(agent_uid)
+        return _etag(jsonify(success(result)), result["current_version"])
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>/revisions", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def revise_governed_agent(agent_uid):
+    try:
+        result = _service().revise_agent(
+            agent_uid, request.get_json(silent=True) or {},
+            expected_version=_expected_version(), actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "Agent 版本草稿已创建")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>/transition", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def transition_governed_agent(agent_uid):
+    try:
+        result = _service().transition_agent(
+            agent_uid, request.get_json(silent=True) or {},
+            expected_version=_expected_version(), actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "Agent 生命周期已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>/grants", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def create_agent_tool_grant(agent_uid):
+    try:
+        result = _service().create_tool_grant(
+            agent_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return jsonify(success(result, "Agent 工具授权已创建", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>/grants/<grant_uid>/revoke", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def revoke_agent_tool_grant(agent_uid, grant_uid):
+    try:
+        return jsonify(success(_service().revoke_tool_grant(
+            agent_uid, grant_uid, actor_uid=g.current_user["id"]
+        ), "Agent 工具授权已撤销"))
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>/credentials", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def issue_agent_credential(agent_uid):
+    try:
+        _require_credential_secret()
+        result = _service().issue_credential(
+            agent_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _no_store(jsonify(success(result, "凭证仅本次返回,请立即安全保存", code=201))), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError, AgentGovernanceUnavailable) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>/credentials/revoke", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def revoke_agent_credentials(agent_uid):
+    try:
+        return jsonify(success(_service().revoke_agent_credentials(
+            agent_uid, actor_uid=g.current_user["id"]
+        ), "Agent 有效凭证已全部撤销"))
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>/actions/authorize", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def authorize_agent_action(agent_uid):
+    try:
+        _require_credential_secret()
+        token = str(request.headers.get("X-Agent-Credential") or "")
+        result = _service().authorize_action(
+            agent_uid, token, request.get_json(silent=True) or {}
+        )
+        return _no_store(_etag(jsonify(success(result, "Agent 策略判定已留痕", code=201)), 1)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError, AgentGovernanceUnavailable) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/actions", methods=["GET"])
+@require_permissions(AGENTS_READ)
+def list_agent_actions():
+    filters = {
+        key: request.args.get(key)
+        for key in ("agent_uid", "decision", "risk_level")
+        if request.args.get(key)
+    }
+    return jsonify(success(_service().list_requests(**filters)))
+
+
+@bp.route("/agents/actions/<request_uid>/reconcile", methods=["POST"])
+@require_permissions(AGENTS_MANAGE)
+def reconcile_agent_action(request_uid):
+    try:
+        result = _service().reconcile_action(
+            request_uid, expected_version=_expected_version(), actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "审批结果已同步")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/actions/<request_uid>/complete", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def complete_agent_action(request_uid):
+    try:
+        result = _service().complete_action(
+            request_uid, request.get_json(silent=True) or {},
+            expected_version=_expected_version(), actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "Agent 执行证据已记录")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/actions/<request_uid>/replay", methods=["GET"])
+@require_permissions(AGENTS_READ)
+def replay_agent_action(request_uid):
+    try:
+        return _no_store(jsonify(success(_service().replay(request_uid))))
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/dashboard", methods=["GET"])
+@require_permissions(AGENTS_READ)
+def agent_governance_dashboard():
+    try:
+        return jsonify(success(_service().dashboard()))
+    except AgentGovernanceUnavailable as exc:
+        return _error(exc)
+
+
+@bp.route("/agents/<agent_uid>/runtime/invocations", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def authorize_runtime_invocation(agent_uid):
+    """The only HTTP path that binds Agent credentials to a server identity."""
+    try:
+        token = str(request.headers.get("X-Agent-Credential") or "")
+        if not token:
+            raise PermissionError("Agent credential is required")
+        _service().validate_credential(agent_uid, token)
+        result = _runtime_service().authorize(
+            RuntimeServerContext(
+                agent_uid=agent_uid,
+                actor_uid=g.current_user["id"],
+                roles=frozenset(g.current_user["roles"]),
+            ),
+            request.get_json(silent=True) or {},
+        )
+        db.session.commit()
+        return _no_store(jsonify(success(result, code=201))), 201
+    except (RuntimeServerError, ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        response, status = _error(exc)
+        return _no_store(response), status
+
+
+@bp.route("/agents/<agent_uid>/runtime/invocations/<idempotency_key>/settle", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def settle_runtime_invocation(agent_uid, idempotency_key):
+    try:
+        token = str(request.headers.get("X-Agent-Credential") or "")
+        if not token:
+            raise PermissionError("Agent credential is required")
+        claims = _service().validate_credential(agent_uid, token)
+        agent = _service().get_agent(agent_uid)
+        if claims["agent_uid"] != agent_uid or agent["owner_uid"] != g.current_user["id"]:
+            raise PermissionError("Agent credential principal does not match")
+        body = request.get_json(silent=True) or {}
+        allowed = {"lease_fence", "outcome", "actual_tokens", "actual_cost_micros", "actual_tools", "actual_time_ms"}
+        if set(body) != allowed:
+            raise ValueError("runtime settlement payload is closed")
+        scope = _runtime_service().repository.settlement_context(
+            agent_uid=agent_uid, actor_uid=g.current_user["id"], idempotency_key=idempotency_key
+        )
+        result = _runtime_service().repository.settle_claim({
+            **scope,
+            "worker_id": f"agent-runtime-http:{g.current_user['id']}",
+            **body,
+        })
+        db.session.commit()
+        if result.get("rejected"):
+            return _no_store(jsonify(failed("runtime_settlement_rejected", code=409))), 409
+        return _no_store(jsonify(success(result, code=201))), 201
+    except (RuntimeServerError, ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        response, status = _error(exc)
+        return _no_store(response), status
+
+
+@bp.route("/agents/<agent_uid>/runtime/control", methods=["POST"])
+@require_permissions(AGENTS_OPERATE)
+def control_runtime(agent_uid):
+    """Execute a recovery/canary change only through an authenticated control claim."""
+    try:
+        token = str(request.headers.get("X-Agent-Credential") or "")
+        if not token:
+            raise PermissionError("Agent credential is required")
+        claims = _service().validate_credential(agent_uid, token)
+        agent = _service().get_agent(agent_uid)
+        if claims["agent_uid"] != agent_uid or agent["owner_uid"] != g.current_user["id"]:
+            raise PermissionError("Agent credential principal does not match")
+        body = request.get_json(silent=True) or {}
+        forbidden = {"agent_uid", "principal_id"}
+        required = {
+            "tenant_id", "business_domain_uid", "environment", "operation", "approval_task_uid",
+            "idempotency_key", "request_digest", "expected_fence", "incident_uid", "from_state",
+            "to_state", "generation", "from_generation", "to_generation", "dataset_version",
+            "dataset_digest", "metrics_digest", "threshold_policy", "issued_at",
+        }
+        if set(body) != required or set(body) & forbidden:
+            raise ValueError("runtime control payload is closed")
+        credential = _service().repository.get_credential(claims["jti"])
+        if not credential:
+            raise PermissionError("Agent credential is revoked or unknown")
+        payload = {**body, "agent_uid": agent_uid, "principal_id": g.current_user["id"]}
+        claim_uid = _runtime_service().repository.issue_control_claim({
+            "claim_uid": str(uuid.uuid4()), "credential_uid": credential["uid"],
+            "credential_token_digest": hashlib.sha256(token.encode()).hexdigest(),
+            "nonce": str(uuid.uuid4()), "control_payload": payload,
+        })
+        result = _runtime_service().repository.control_claimed(payload, claim_uid)
+        db.session.commit()
+        return _no_store(jsonify(success(result, code=201))), 201
+    except (RuntimeServerError, ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        response, status = _error(exc)
+        return _no_store(response), status

+ 200 - 7
app/api/knowledge_base/routes.py

@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import uuid
+from time import perf_counter
 
 from flask import current_app, g, jsonify, request
 from sqlalchemy import text
@@ -16,7 +17,21 @@ from app.core.knowledge.admin import (
     rollback_change_set,
 )
 from app.core.knowledge.audit import run_canonical_audit
+from app.core.knowledge.device_scope import (
+    DeviceSourceScopeError,
+    DeviceSourceScopeService,
+    SqlAlchemyDeviceSourceScopeRepository,
+    source_scope_access,
+)
 from app.core.knowledge.qa import AnswerSynthesizer, DeepSeekAnswerModel
+from app.core.knowledge.query_audit import (
+    SqlKnowledgeQueryAuditRepository,
+    build_query_audit,
+)
+from app.core.knowledge.retrieval.device import (
+    SqlDeviceKnowledgeRepository,
+    SqlDeviceKnowledgeRetriever,
+)
 from app.core.knowledge.retrieval.pipeline import KnowledgeRetrievalPipeline
 from app.core.knowledge.retrieval.sql import (
     SqlLexicalRetriever,
@@ -55,6 +70,7 @@ def _pipeline() -> KnowledgeRetrievalPipeline:
     return KnowledgeRetrievalPipeline(
         lexical=SqlLexicalRetriever(db.session),
         vector=vector,
+        device=SqlDeviceKnowledgeRetriever(_device_repository()),
     )
 
 
@@ -82,6 +98,61 @@ def _answer_synthesizer() -> AnswerSynthesizer:
     return configured or AnswerSynthesizer(DeepSeekAnswerModel())
 
 
+def _device_repository():
+    configured = current_app.extensions.get("device_knowledge_repository")
+    return configured or SqlDeviceKnowledgeRepository(db.session)
+
+
+def _scope_service():
+    configured = current_app.extensions.get("device_source_scope_service")
+    return configured or DeviceSourceScopeService(
+        SqlAlchemyDeviceSourceScopeRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _audit_repository():
+    configured = current_app.extensions.get(
+        "knowledge_query_audit_repository"
+    )
+    return configured or SqlKnowledgeQueryAuditRepository(db.session)
+
+
+def _persist_query_audit(
+    *,
+    query,
+    context,
+    result,
+    cited_points=(),
+    started_at,
+) -> bool:
+    record = build_query_audit(
+        query=query,
+        context=context,
+        mode=result.mode,
+        evidence=result.evidence,
+        cited_points=tuple(cited_points),
+        degraded_components=result.degraded_components,
+        latency_ms=int((perf_counter() - started_at) * 1000),
+    )
+    try:
+        _audit_repository().record(record)
+        return True
+    except Exception:
+        current_app.logger.exception("mandatory knowledge query audit failed")
+        return False
+
+
+def _query(payload) -> str:
+    query = str(payload.get("query") or "").strip()
+    if not query:
+        raise ValueError("query 不能为空")
+    if len(query) > 300:
+        raise ValueError("query 不能超过 300 个字符")
+    return query
+
+
 def _request_context(payload):
     requested_domains = payload.get("business_domain_uids")
     if requested_domains is not None and not isinstance(requested_domains, list):
@@ -99,9 +170,10 @@ def _request_context(payload):
 @bp.post("/search")
 def search():
     payload = request.get_json(silent=True) or {}
-    query = str(payload.get("query") or "").strip()
-    if not query:
-        return jsonify(failed("query 不能为空", code=400)), 400
+    try:
+        query = _query(payload)
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
     mode = str(payload.get("mode") or "auto")
     if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
         return jsonify(failed("不支持的检索模式", code=400)), 400
@@ -112,12 +184,20 @@ def search():
         context, correlation_id = _request_context(payload)
     except ValueError as exc:
         return jsonify(failed(str(exc), code=400)), 400
+    started_at = perf_counter()
     result = _pipeline().search(
         query,
         context=context,
         mode=mode,
         limit=limit,
     )
+    if not _persist_query_audit(
+        query=query,
+        context=context,
+        result=result,
+        started_at=started_at,
+    ):
+        return jsonify(failed("知识查询审计写入失败", code=503)), 503
     return jsonify(
         success(
             {
@@ -134,9 +214,10 @@ def search():
 @bp.post("/ask")
 def ask():
     payload = request.get_json(silent=True) or {}
-    query = str(payload.get("query") or "").strip()
-    if not query:
-        return jsonify(failed("query 不能为空", code=400)), 400
+    try:
+        query = _query(payload)
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
     mode = str(payload.get("mode") or "auto")
     if mode not in {"auto", "exact", "semantic", "relationship", "global"}:
         return jsonify(failed("不支持的检索模式", code=400)), 400
@@ -144,8 +225,34 @@ def ask():
         context, correlation_id = _request_context(payload)
     except ValueError as exc:
         return jsonify(failed(str(exc), code=400)), 400
+    started_at = perf_counter()
     retrieval = _pipeline().search(query, context=context, mode=mode, limit=30)
     answer = _answer_synthesizer().answer(query, retrieval.evidence)
+    cited_points = tuple(
+        (citation.point_key, citation.point_revision)
+        for citation in answer.citations
+    )
+    if not _persist_query_audit(
+        query=query,
+        context=context,
+        result=retrieval,
+        cited_points=cited_points,
+        started_at=started_at,
+    ):
+        return jsonify(failed("知识查询审计写入失败", code=503)), 503
+    evidence_by_chunk = {
+        item.chunk_id: _serialize_evidence(item)
+        for item in retrieval.evidence
+    }
+    citations = []
+    for citation in answer.citations:
+        serialized = dict(citation.__dict__)
+        selected = evidence_by_chunk.get(citation.chunk_id, {})
+        serialized["content"] = selected.get("content")
+        serialized["business_domain_uid"] = selected.get(
+            "business_domain_uid"
+        )
+        citations.append(serialized)
     return jsonify(
         success(
             {
@@ -154,7 +261,8 @@ def ask():
                 "answer": answer.answer,
                 "answer_status": answer.status,
                 "degraded_components": list(retrieval.degraded_components),
-                "citations": [citation.__dict__ for citation in answer.citations],
+                "citations": citations,
+                "evidence": list(evidence_by_chunk.values()),
                 "freshness_status": answer.freshness_status,
             }
         )
@@ -192,6 +300,41 @@ def _source_document(source_uid: str, version: int | None = None):
 @bp.get("/sources/<source_uid>")
 def source(source_uid: str):
     row = _source_document(source_uid)
+    if row is None:
+        context, _correlation_id = _request_context({})
+        device = _device_repository().get_detail(
+            source_uid,
+            global_access=context.global_access,
+            business_domain_uids=tuple(
+                sorted(context.business_domain_uids)
+            ),
+        )
+        if device is not None:
+            row = {
+                "object_uid": device.asset_uid,
+                "object_type": "DeviceAsset",
+                "object_version": device.current_version,
+                "object_name": device.name,
+                "asset_type": device.asset_type,
+                "business_domain_uid": device.business_domain_uid,
+                "location": device.location,
+                "organization": device.organization,
+                "responsible_person": device.responsible_person,
+                "source_codes": list(device.source_codes),
+                "related_events": [
+                    {
+                        "event_type": event_type,
+                        "title": title,
+                        "source_code": source_code,
+                    }
+                    for event_type, title, source_code in device.related_events
+                ],
+                "source_updated_at": (
+                    device.updated_at.isoformat()
+                    if device.updated_at is not None
+                    else None
+                ),
+            }
     if row is None:
         return jsonify(failed("来源不存在或无权访问", code=404)), 404
     return jsonify(success(row))
@@ -223,6 +366,56 @@ def capabilities():
     )
 
 
+@bp.get("/admin/device-sources")
+def admin_device_sources():
+    return jsonify(success(list(_scope_service().list())))
+
+
+@bp.put("/admin/device-sources/<source_uid>/scope")
+def admin_device_source_scope(source_uid: str):
+    payload = request.get_json(silent=True) or {}
+    try:
+        source_record = _scope_service().update(
+            source_uid,
+            payload,
+            actor_is_admin="admin" in g.current_user.get("roles", ()),
+        )
+    except DeviceSourceScopeError as exc:
+        return jsonify(failed(str(exc), code=exc.http_status)), exc.http_status
+    access = source_scope_access(source_record.permission_scope)
+    return jsonify(
+        success(
+            {
+                "uid": source_record.uid,
+                "name": source_record.name,
+                "source_type": source_record.source_type,
+                "status": source_record.status,
+                **access,
+                "updated_at": (
+                    source_record.updated_at.isoformat()
+                    if source_record.updated_at is not None
+                    else None
+                ),
+            }
+        )
+    )
+
+
+@bp.get("/admin/query-audits")
+def admin_query_audits():
+    try:
+        limit = _bounded_int(
+            request.args.get("limit"),
+            field="limit",
+            default=100,
+            lower=1,
+            upper=200,
+        )
+    except ValueError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+    return jsonify(success(list(_audit_repository().list(limit=limit))))
+
+
 @bp.get("/admin/sync")
 def admin_sync():
     return jsonify(success(knowledge_status(db.session)))

+ 7 - 1
app/api/meta_data/__init__.py

@@ -1,5 +1,11 @@
+# ruff: noqa: E402, F401
+
 from flask import Blueprint
 
 bp = Blueprint("meta_data", __name__)
 
-from app.api.meta_data import routes  # noqa: E402, F401
+from app.api.meta_data import (
+    active_metadata,
+    domain_templates,
+    routes,
+)

+ 154 - 0
app/api/meta_data/active_metadata.py

@@ -0,0 +1,154 @@
+"""HTTP API for active metadata discovery and field lineage."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.meta_data import bp
+from app.core.meta_data.active_metadata import (
+    ActiveMetadataConflict,
+    ActiveMetadataError,
+    ActiveMetadataNotFound,
+    ActiveMetadataService,
+)
+from app.core.meta_data.active_metadata_repository import (
+    SqlAlchemyActiveMetadataRepository,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return ActiveMetadataService(SqlAlchemyActiveMetadataRepository(db.session))
+
+
+def _error(exc):
+    db.session.rollback()
+    if isinstance(exc, ActiveMetadataNotFound):
+        return jsonify(failed(str(exc), code=404)), 404
+    if isinstance(exc, ActiveMetadataConflict):
+        return jsonify(failed(str(exc), code=409)), 409
+    if isinstance(exc, ActiveMetadataError):
+        return jsonify(failed(str(exc), code=400)), 400
+    raise exc
+
+
+@bp.route("/active-metadata/plans", methods=["GET"])
+def list_active_metadata_plans():
+    return jsonify(success(_service().list_plans()))
+
+
+@bp.route("/active-metadata/plans", methods=["POST"])
+def create_active_metadata_plan():
+    try:
+        result = _service().create_plan(
+            request.get_json(silent=True),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "主动发现计划已创建")), 201
+    except Exception as exc:
+        return _error(exc)
+
+@bp.route("/active-metadata/plans/<plan_uid>/runs", methods=["POST"])
+def execute_active_metadata_plan(plan_uid):
+    batch_key = str(request.headers.get("Idempotency-Key") or "").strip()
+    if not batch_key:
+        return jsonify(failed("Idempotency-Key is required", code=428)), 428
+    try:
+        result = _service().execute(
+            plan_uid,
+            request.get_json(silent=True),
+            batch_key=batch_key,
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "主动发现批次已完成"))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/plans/<plan_uid>/runs", methods=["GET"])
+def list_active_metadata_runs(plan_uid):
+    try:
+        return jsonify(success(_service().list_runs(plan_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/assets", methods=["GET"])
+def list_active_metadata_assets():
+    try:
+        return jsonify(
+            success(_service().list_assets(request.args.get("source_uid")))
+        )
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/changes", methods=["GET"])
+def list_active_metadata_changes(run_uid):
+    try:
+        return jsonify(success(_service().list_changes(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/lineage", methods=["GET"])
+def list_active_metadata_lineage(run_uid):
+    try:
+        return jsonify(success(_service().list_lineage(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/runs/<run_uid>/health-signals", methods=["GET"])
+def list_active_metadata_health(run_uid):
+    try:
+        return jsonify(success(_service().list_health_signals(run_uid)))
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/corrections", methods=["GET"])
+def list_active_metadata_corrections():
+    try:
+        return jsonify(
+            success(_service().list_corrections(request.args.get("asset_uid")))
+        )
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route("/active-metadata/assets/<asset_uid>/corrections", methods=["POST"])
+def submit_active_metadata_correction(asset_uid):
+    try:
+        result = _service().submit_correction(
+            asset_uid,
+            request.get_json(silent=True),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "元数据纠错已提交")), 201
+    except Exception as exc:
+        return _error(exc)
+
+
+@bp.route(
+    "/active-metadata/corrections/<correction_uid>/resolve",
+    methods=["POST"],
+)
+def resolve_active_metadata_correction(correction_uid):
+    body = request.get_json(silent=True) or {}
+    try:
+        result = _service().resolve_correction(
+            correction_uid,
+            expected_version=body.get("expected_version"),
+            decision=body.get("decision"),
+            resolution=body.get("resolution") or {},
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "元数据纠错已处置"))
+    except Exception as exc:
+        return _error(exc)

+ 95 - 0
app/api/meta_data/domain_templates.py

@@ -0,0 +1,95 @@
+"""Management API for generic governance domain templates."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.meta_data import bp
+from app.core.governance.domain_template_repository import (
+    SqlAlchemyDomainTemplateRepository,
+)
+from app.core.governance.domain_templates import (
+    DomainTemplateNotFound,
+    DomainTemplateService,
+    DomainTemplateValidationError,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return DomainTemplateService(SqlAlchemyDomainTemplateRepository(db.session))
+
+
+@bp.route("/domain-templates", methods=["GET"])
+def list_domain_templates():
+    return jsonify(success(_service().list_templates()))
+
+
+@bp.route("/domain-templates/<template_code>", methods=["GET"])
+def get_domain_template(template_code):
+    try:
+        return jsonify(success(_service().get_template(template_code)))
+    except DomainTemplateNotFound as exc:
+        return jsonify(failed(str(exc), code=404)), 404
+    except DomainTemplateValidationError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.route("/domain-templates/<template_code>/imports", methods=["GET"])
+def list_domain_template_imports(template_code):
+    try:
+        return jsonify(success(_service().list_imports(template_code)))
+    except DomainTemplateValidationError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.route("/domain-templates/dry-run", methods=["POST"])
+def dry_run_domain_template():
+    try:
+        return jsonify(success(_service().dry_run(request.get_json(silent=True))))
+    except DomainTemplateValidationError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.route("/domain-templates/import", methods=["POST"])
+def import_domain_template():
+    try:
+        result = _service().import_template(
+            request.get_json(silent=True),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "领域模板已导入"))
+    except DomainTemplateValidationError as exc:
+        db.session.rollback()
+        return jsonify(failed(str(exc), code=400)), 400
+    except Exception:
+        db.session.rollback()
+        raise
+
+@bp.route("/domain-templates/<template_code>/rollback", methods=["POST"])
+def rollback_domain_template(template_code):
+    try:
+        body = request.get_json(silent=True) or {}
+        target_version = body.get("target_version")
+        if isinstance(target_version, bool):
+            target_version = None
+        if isinstance(target_version, str) and target_version.isdigit():
+            target_version = int(target_version)
+        result = _service().rollback(
+            template_code,
+            target_version=target_version,
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return jsonify(success(result, "领域模板已回滚为新版本"))
+    except DomainTemplateNotFound as exc:
+        db.session.rollback()
+        return jsonify(failed(str(exc), code=404)), 404
+    except DomainTemplateValidationError as exc:
+        db.session.rollback()
+        return jsonify(failed(str(exc), code=400)), 400
+    except Exception:
+        db.session.rollback()
+        raise

+ 13 - 0
app/api/system/__init__.py

@@ -1,7 +1,20 @@
+# ruff: noqa: E402, F401, I001
+
 from flask import Blueprint
 
 bp = Blueprint("system", __name__)
 
+from app.api.system import governance_audit  # noqa: E402, F401
+from app.api.system import enterprise_identity  # noqa: E402, F401
+from app.api.system import bi_ai_catalog_routes  # noqa: E402, F401
+from app.api.system import metering_showback_routes  # noqa: E402, F401
+from app.api.system import plugin_platform_routes  # noqa: E402, F401
+from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import routes  # noqa: E402, F401
+from app.api.system import security_governance  # noqa: E402, F401
+from app.api.system import trusted_delivery  # noqa: E402, F401
+from app.api.system import trusted_delivery_controls  # noqa: E402, F401
+from app.api.system import tenant_routes  # noqa: E402, F401
 from app.api.system import users  # noqa: E402, F401
 from app.api.system import workbench  # noqa: E402, F401
+from app.api.system import work_center  # noqa: E402, F401

+ 166 - 0
app/api/system/bi_ai_catalog_routes.py

@@ -0,0 +1,166 @@
+"""Closed, RBAC-protected HTTP contract for the local WP11 catalog fixture."""
+
+from __future__ import annotations
+
+import os
+
+from flask import current_app, g, jsonify, request
+from sqlalchemy import create_engine
+
+from app import db
+from app.api.system import bp
+from app.config.database_urls import validate_postgresql_url
+from app.core.governance.bi_ai_asset_catalog import (
+    CatalogApplicationService,
+    CatalogControlIssuer,
+)
+from app.core.system.permissions import (
+    BI_AI_CATALOG_MANAGE,
+    BI_AI_CATALOG_READ,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+_MAX_BODY_BYTES = 4096
+
+
+def _service():
+    control_url = os.environ.get("BI_AI_CATALOG_CONTROL_DATABASE_URL", "")
+    control = validate_postgresql_url(control_url, "BI_AI_CATALOG_CONTROL_DATABASE_URL")
+    runtime = validate_postgresql_url(
+        str(current_app.config.get("SQLALCHEMY_DATABASE_URI", "")), "DATABASE_URL"
+    )
+    if control.username != "dataops_bi_ai_catalog_control" or control.username == runtime.username:
+        raise RuntimeError("catalog control identity is not configured")
+    return CatalogApplicationService(
+        db.session,
+        CatalogControlIssuer(create_engine(control_url, pool_pre_ping=True)),
+    )
+
+
+def _closed_body(required: set[str]) -> dict:
+    if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
+        raise ValueError("catalog request rejected")
+    body = request.get_json(silent=True)
+    if not isinstance(body, dict) or set(body) != required:
+        raise ValueError("catalog request rejected")
+    return body
+
+
+def _response(value, status: int = 200):
+    response = jsonify(success(value, code=status))
+    response.headers["Cache-Control"] = "no-store"
+    return response, status
+
+
+def _failure(status: int = 400):
+    response = jsonify(failed("catalog request rejected", code=status))
+    response.headers["Cache-Control"] = "no-store"
+    return response, status
+
+
+@bp.post("/bi-ai-catalog/local-fixture/sync")
+@require_permissions(BI_AI_CATALOG_MANAGE)
+def sync_local_fixture():
+    try:
+        body = _closed_body(set())
+        service = _service()
+        if hasattr(service, "sync_local_fixture"):
+            return _response(
+                service.sync_local_fixture(
+                    principal_id=str(g.current_user["id"]), body=body
+                ),
+                201,
+            )
+        return _response(
+            {
+                "source": "local-fixture-v1",
+                "mode": "ENGINEERING_EVIDENCE_ONLY",
+                "persisted_before_ack": False,
+            },
+            201,
+        )
+    except (KeyError, PermissionError, RuntimeError, TypeError, ValueError):
+        return _failure()
+
+
+@bp.post("/bi-ai-catalog/assets/search")
+@require_permissions(BI_AI_CATALOG_READ)
+def search_catalog_assets():
+    try:
+        body = _closed_body({"query", "filters"})
+        service = _service()
+        if hasattr(service, "search_for_principal"):
+            return _response(
+                service.search_for_principal(
+                    principal_id=str(g.current_user["id"]),
+                    query=body["query"],
+                    filters=body["filters"],
+                )
+            )
+        return _response(
+            service.search(
+                body["query"],
+                body["filters"],
+                principal_roles=set(g.current_user.get("roles", [])),
+                domain="default",
+            )
+        )
+    except ValueError:
+        return _failure(400)
+    except (KeyError, LookupError, PermissionError, RuntimeError, TypeError):
+        return _failure(403)
+
+
+@bp.get("/bi-ai-catalog/assets/<external_uid>/impact")
+@require_permissions(BI_AI_CATALOG_READ)
+def catalog_asset_impact(external_uid: str):
+    try:
+        if set(request.args) - {"max_depth", "max_nodes"}:
+            raise ValueError("catalog request rejected")
+        max_depth, max_nodes = (
+            int(request.args.get("max_depth", "3")),
+            int(request.args.get("max_nodes", "50")),
+        )
+        service = _service()
+        if hasattr(service, "impact_for_principal"):
+            return _response(
+                service.impact_for_principal(
+                    principal_id=str(g.current_user["id"]),
+                    external_uid=external_uid,
+                    max_depth=max_depth,
+                    max_nodes=max_nodes,
+                )
+            )
+        return _response(
+            service.impact(
+                external_uid,
+                principal_roles=set(g.current_user.get("roles", [])),
+                domain="default",
+                max_depth=max_depth,
+                max_nodes=max_nodes,
+            )
+        )
+    except (
+        KeyError,
+        LookupError,
+        PermissionError,
+        RuntimeError,
+        TypeError,
+        ValueError,
+    ):
+        return _failure(403)
+
+
+@bp.get("/bi-ai-catalog/audit")
+@require_permissions(BI_AI_CATALOG_READ)
+def catalog_audit():
+    try:
+        if request.args:
+            raise ValueError("catalog request rejected")
+        service = _service()
+        return _response(
+            service.audit_for_principal(principal_id=str(g.current_user["id"]))
+        )
+    except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError):
+        return _failure(403)

+ 648 - 0
app/api/system/enterprise_identity.py

@@ -0,0 +1,648 @@
+"""Enterprise identity APIs. Public routes are explicitly enumerated in policy."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import secrets
+import uuid
+from datetime import UTC, datetime, timedelta
+from functools import wraps
+from urllib.parse import urlsplit
+
+from flask import current_app, g, jsonify, redirect, request
+from itsdangerous import BadSignature, URLSafeTimedSerializer
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError, StatementError
+
+from app import db
+from app.api.system import bp
+from app.core.common.identifiers import new_governance_uid
+from app.core.system.enterprise_identity import (
+    ClaimMapper,
+    DirectorySynchronizer,
+    EmergencyAccess,
+    IdentityPolicyError,
+    IdentityUpstreamError,
+    IdpConfig,
+    sanitize_audit_detail,
+)
+from app.core.system.identity_repository import PostgresIdentityRepository
+from app.core.system.identity_sessions import SessionManager
+from app.core.system.oidc import OidcClient, OidcTransport
+from app.core.system.permissions import (
+    IDENTITY_MANAGE,
+    IDENTITY_OPERATE,
+    IDENTITY_READ,
+    require_permissions,
+)
+from app.core.system.tokens import issue_access_token
+from app.models.result import failed, success
+
+
+def _body() -> dict:
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict):
+        raise IdentityPolicyError("JSON object body is required")
+    return value
+
+
+def _required_str(data: dict, key: str, *, maximum: int = 500) -> str:
+    value = data.get(key)
+    if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum:
+        raise IdentityPolicyError(f"{key} is required or invalid")
+    return value.strip()
+
+
+def _optional_str(data: dict, key: str, default: str, *, maximum: int) -> str:
+    if key not in data:
+        return default
+    return _required_str(data, key, maximum=maximum)
+
+
+def _uuid(value, field: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (ValueError, TypeError, AttributeError) as exc:
+        raise IdentityPolicyError(f"{field} must be a UUID") from exc
+
+
+def _positive_int(value, field: str, *, maximum: int = 2_147_483_647) -> int:
+    if isinstance(value, bool):
+        raise IdentityPolicyError(f"{field} must be a positive integer")
+    try:
+        result = int(value)
+    except (ValueError, TypeError) as exc:
+        raise IdentityPolicyError(f"{field} must be a positive integer") from exc
+    if result < 1 or result > maximum:
+        raise IdentityPolicyError(f"{field} must be a positive integer")
+    return result
+
+
+def _timestamp(value, field: str) -> datetime:
+    if not isinstance(value, str):
+        raise IdentityPolicyError(f"{field} must be an ISO-8601 timestamp")
+    try:
+        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+    except ValueError as exc:
+        raise IdentityPolicyError(f"{field} must be an ISO-8601 timestamp") from exc
+    if parsed.tzinfo is None or parsed.utcoffset() is None:
+        raise IdentityPolicyError(f"{field} must include a timezone")
+    return parsed
+
+
+def _string_array(data: dict, key: str, *, default=None) -> tuple[str, ...]:
+    value = data.get(key, default)
+    if not isinstance(value, list) or not value or any(not isinstance(item, str) or not item.strip() for item in value):
+        raise IdentityPolicyError(f"{key} must be a non-empty string array")
+    return tuple(item.strip() for item in value)
+
+
+def _validate_mapping(version: str, group_rules) -> ClaimMapper:
+    if not isinstance(group_rules, dict):
+        raise IdentityPolicyError("group_rules must be an object")
+    return ClaimMapper(version=version, group_rules=group_rules)
+
+
+def _identity_input_guard(view):
+    @wraps(view)
+    def guarded(*args, **kwargs):
+        try:
+            return view(*args, **kwargs)
+        except IdentityPolicyError:
+            raise
+        except (KeyError, TypeError, ValueError, IntegrityError, StatementError) as exc:
+            db.session.rollback()
+            raise IdentityPolicyError("invalid enterprise identity request") from exc
+
+    return guarded
+
+
+def _repo() -> PostgresIdentityRepository:
+    return PostgresIdentityRepository(db.session)
+
+
+def _config(row) -> IdpConfig:
+    return IdpConfig(provider_uid=str(row["provider_uid"]), version=row["version"], issuer=row["issuer"],
+                     client_id=row["client_id"], secret_ref=row["secret_ref"],
+                     authorization_endpoint=row["authorization_endpoint"], token_endpoint=row["token_endpoint"],
+                     jwks_uri=row["jwks_uri"], redirect_uris=tuple(row["redirect_uris"]),
+                     post_login_redirect_uris=tuple(dict(row["config"]).get("post_login_redirect_uris", ())),
+                     algorithms=tuple(row["algorithms"]), mapping_version=row["mapping_version"], status=row["status"])
+
+
+def _load_config(provider_uid: str, *, version: int | None = None, active: bool = True):
+    clause = "AND status='active'" if active else ""
+    version_clause = "AND version=:version" if version is not None else ""
+    return db.session.execute(text(f"""
+        SELECT provider_uid::text,version,status,issuer,client_id,secret_ref,authorization_endpoint,
+               token_endpoint,jwks_uri,redirect_uris,algorithms,mapping_version,config
+        FROM public.identity_idp_config_versions WHERE provider_uid=CAST(:provider AS uuid) {version_clause} {clause}
+        ORDER BY version DESC LIMIT 1
+    """), {"provider": provider_uid, "version": version}).mappings().one_or_none()
+
+
+def _audit(event_type: str, outcome: str, *, actor_uid: str | None = None,
+           provider_uid: str | None = None, resource_uid: str | None = None,
+           enterprise_subject: str | None = None, detail: dict | None = None) -> None:
+    _repo().audit({"event_type": event_type, "outcome": outcome, "actor_uid": actor_uid,
+                   "provider_uid": provider_uid, "resource_type": "enterprise_identity",
+                   "resource_uid": resource_uid,
+                   "subject_digest": (hashlib.sha256(enterprise_subject.encode()).hexdigest()
+                                      if enterprise_subject else None),
+                   "safe_detail": sanitize_audit_detail(detail or {})})
+
+
+def _cookie_secure() -> bool:
+    return bool(current_app.config.get("IDENTITY_COOKIE_SECURE", True))
+
+
+def _origin(uri: str) -> str:
+    parts = urlsplit(uri)
+    return f"{parts.scheme}://{parts.netloc}"
+
+
+def _browser_origin_allowed(origin: str) -> bool:
+    rows = db.session.execute(text("SELECT config FROM public.identity_idp_config_versions WHERE status='active'")).scalars().all()
+    allowed = {_origin(uri) for config in rows for uri in dict(config).get("post_login_redirect_uris", ())}
+    allowed.update(current_app.config.get("IDENTITY_ALLOWED_TEST_ORIGINS", ()))
+    return origin in allowed
+
+
+@bp.errorhandler(IdentityPolicyError)
+def identity_policy_error(exc):
+    try:
+        db.session.rollback()
+        _audit("identity_policy_denied", "denied", actor_uid=getattr(g, "current_user", {}).get("id"),
+               detail={"path": request.path, "reason": str(exc)[:160]})
+    except Exception:  # pragma: no cover - failure reporting must not expose internals
+        db.session.rollback()
+    return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.errorhandler(IdentityUpstreamError)
+def identity_upstream_error(exc):
+    db.session.rollback()
+    try:
+        _audit("identity_upstream_failure", "failure", actor_uid=getattr(g, "current_user", {}).get("id"),
+               detail={"path": request.path})
+    except Exception:  # pragma: no cover
+        db.session.rollback()
+    return jsonify(failed("enterprise identity provider is unavailable", code=502)), 502
+
+
+@bp.route("/identity/providers", methods=["GET"])
+def identity_providers():
+    rows = db.session.execute(text("""
+        SELECT provider_uid::text,version,issuer,config->>'display_name' display_name
+        FROM public.identity_idp_config_versions WHERE status='active' ORDER BY provider_uid
+    """)).mappings().all()
+    return jsonify(success([{"provider_uid": row["provider_uid"], "version": row["version"],
+                             "issuer": row["issuer"], "display_name": row["display_name"] or "企业单点登录"} for row in rows]))
+
+
+@bp.route("/identity/authorize", methods=["POST"])
+@_identity_input_guard
+def identity_authorize():
+    data = _body()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    row = _load_config(provider_uid)
+    if not row:
+        raise IdentityPolicyError("active identity provider not found")
+    config = _config(row)
+    callback_uri = _required_str(data, "redirect_uri", maximum=2048)
+    post_login_uri = _required_str(data, "post_login_uri", maximum=2048)
+    if post_login_uri not in config.post_login_redirect_uris:
+        raise IdentityPolicyError("post-login redirect URI is not allowlisted")
+    started = OidcClient(_repo()).begin(config, callback_uri)
+    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="oidc-flow")
+    response = jsonify(success({"authorization_url": started.url, "provider_uid": config.provider_uid}))
+    response.set_cookie("dataops_oidc_flow", serializer.dumps({"state": started.state, "verifier": started.code_verifier,
+                                                                 "provider_uid": config.provider_uid,
+                                                                 "redirect_uri": callback_uri,
+                                                                 "post_login_uri": post_login_uri}),
+                        httponly=True, secure=_cookie_secure(), samesite="Lax", max_age=300, path="/api/system/identity/callback")
+    _audit("oidc_authorize", "success", provider_uid=config.provider_uid, detail={"redirect_uri": data["redirect_uri"]})
+    return response
+
+
+@bp.route("/identity/callback", methods=["GET"])
+@_identity_input_guard
+def identity_callback():
+    serializer = URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt="oidc-flow")
+    try:
+        flow = serializer.loads(request.cookies.get("dataops_oidc_flow", ""), max_age=300)
+    except BadSignature as exc:
+        raise IdentityPolicyError("OIDC browser flow binding is invalid or expired") from exc
+    if not isinstance(flow, dict) or request.args.get("state") != flow.get("state"):
+        raise IdentityPolicyError("OIDC state mismatch")
+    provider_uid = _uuid(flow.get("provider_uid"), "provider_uid")
+    row = _load_config(provider_uid)
+    if not row:
+        raise IdentityPolicyError("active identity provider not found")
+    row = dict(row)
+    config = _config(row)
+    config.validate()
+    callback_uri = _required_str(flow, "redirect_uri", maximum=2048)
+    verifier = _required_str(flow, "verifier", maximum=512)
+    state = _required_str(flow, "state", maximum=512)
+    post_login_uri = _required_str(flow, "post_login_uri", maximum=2048)
+    code = request.args.get("code")
+    if not isinstance(code, str) or not code or len(code) > 4096:
+        raise IdentityPolicyError("OIDC authorization code is required")
+    # Do not keep a database transaction open while calling the external IdP.
+    db.session.rollback()
+    transport = OidcTransport()
+    id_token = transport.exchange_code(config, code=code, redirect_uri=callback_uri, verifier=verifier)
+    jwks = transport.fetch_jwks(config)
+    repo = _repo()
+    try:
+        claims = OidcClient(repo).verify_callback(config, state=state, redirect_uri=callback_uri,
+                                                   code_verifier=verifier, id_token=id_token, jwks=jwks,
+                                                   commit=False)
+        mapped = ClaimMapper(version=config.mapping_version,
+                             group_rules=dict(row["config"]).get("group_rules", {})).map(claims)
+        link = repo.get_identity(config.provider_uid, mapped.subject, for_update=True)
+        if link and link["status"] != "active":
+            raise IdentityPolicyError("enterprise identity is disabled")
+        scope = {"business_domain_uids": mapped.business_domain_uids, "object_types": mapped.object_types,
+                 "environments": mapped.environments, "data_scopes": mapped.data_scopes}
+        changed = bool(link and (sorted(link["roles"]) != sorted(mapped.roles)
+                                 or dict(link["authorization_scope"]) != json.loads(json.dumps(scope))))
+        user_uid = link["user_uid"] if link else new_governance_uid()
+        token_version = (int(link["token_version"]) + 1 if changed else int(link["token_version"])) if link else 1
+        repo.put_identity(config.provider_uid, mapped.subject, {
+            "provider_uid": config.provider_uid, "user_uid": user_uid, "username": mapped.username,
+            "display_name": mapped.display_name, "department": mapped.department, "groups": list(mapped.groups),
+            "roles": list(mapped.roles), "authorization_scope": scope, "mapping_version": mapped.mapping_version,
+            "claims_digest": mapped.claims_digest, "status": "active", "token_version": token_version,
+        }, commit=False)
+        if changed:
+            repo.revoke_subject_sessions(config.provider_uid, mapped.subject,
+                                         reason="claims_mapping_changed", commit=False)
+        credentials = SessionManager(repo, secret=current_app.config["SECRET_KEY"]).create(
+            subject=mapped.subject, user_uid=user_uid, roles=list(mapped.roles), identity_source="oidc",
+            token_version=token_version, provider_uid=config.provider_uid, commit=False)
+        exchange = secrets.token_urlsafe(40)
+        repo.insert_exchange_code({"uid": new_governance_uid(),
+                                   "code_hash": hashlib.sha256(exchange.encode()).hexdigest(),
+                                   "session_uid": credentials.session_uid, "redirect_uri": post_login_uri,
+                                   "expires_at": datetime.now(UTC) + timedelta(minutes=2)}, commit=False)
+        db.session.commit()
+    except IdentityPolicyError:
+        db.session.rollback()
+        raise
+    except Exception as exc:
+        db.session.rollback()
+        raise IdentityPolicyError("OIDC callback persistence failed") from exc
+    _audit("oidc_login", "success", provider_uid=config.provider_uid, resource_uid=user_uid,
+           detail={"mapping_version": mapped.mapping_version, "claims_digest": mapped.claims_digest})
+    response = redirect(f"{flow['post_login_uri']}?exchange_code={exchange}", code=302)
+    response.set_cookie("dataops_refresh", credentials.refresh_token, httponly=True, secure=_cookie_secure(), samesite="Strict",
+                        max_age=8 * 3600, path="/api/system/identity")
+    response.delete_cookie("dataops_oidc_flow", path="/api/system/identity/callback")
+    return response
+
+
+@bp.route("/identity/exchange", methods=["POST"])
+@_identity_input_guard
+def identity_exchange():
+    data = _body()
+    exchange_code = _required_str(data, "exchange_code", maximum=1024)
+    redirect_uri = _required_str(data, "redirect_uri", maximum=2048)
+    digest = hashlib.sha256(exchange_code.encode()).hexdigest()
+    row = db.session.execute(text("""
+        UPDATE public.identity_exchange_codes SET status='consumed',consumed_at=CURRENT_TIMESTAMP
+        WHERE code_hash=:digest AND status='pending' AND expires_at>CURRENT_TIMESTAMP
+          AND redirect_uri=:redirect RETURNING session_uid::text,redirect_uri
+    """), {"digest": digest, "redirect": redirect_uri}).mappings().one_or_none()
+    if not row or request.headers.get("Origin") != _origin(row["redirect_uri"]):
+        db.session.rollback()
+        raise IdentityPolicyError("exchange code or browser origin is invalid")
+    db.session.commit()
+    session = _repo().get_session(row["session_uid"])
+    if not session or session["status"] != "active":
+        raise IdentityPolicyError("identity session is unavailable")
+    token = issue_access_token(user_id=session["user_uid"], roles=list(session["roles"]), secret=current_app.config["SECRET_KEY"],
+                               session_uid=session["uid"], token_version=session["token_version"], identity_source=session["identity_source"])
+    return jsonify(success({"token": token, "identity_source": session["identity_source"]}))
+
+
+@bp.route("/identity/refresh", methods=["POST"])
+def identity_refresh():
+    origin = request.headers.get("Origin", "")
+    if not _browser_origin_allowed(origin):
+        raise IdentityPolicyError("browser origin is not allowlisted")
+    credentials = SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).refresh(request.cookies.get("dataops_refresh", ""))
+    session = _repo().get_session(credentials.session_uid)
+    _audit("identity_refresh", "success", actor_uid=session["user_uid"] if session else None,
+           provider_uid=session.get("provider_uid") if session else None,
+           resource_uid=credentials.session_uid,
+           detail={"rotated_from_uid": session.get("rotated_from_uid") if session else None})
+    response = jsonify(success({"token": credentials.access_token}))
+    response.set_cookie("dataops_refresh", credentials.refresh_token, httponly=True, secure=_cookie_secure(), samesite="Strict",
+                        max_age=8 * 3600, path="/api/system/identity")
+    return response
+
+
+@bp.route("/identity/logout", methods=["POST"])
+@require_permissions(IDENTITY_READ)
+def identity_logout():
+    session_uid = g.current_user.get("sid")
+    session = _repo().get_session(session_uid) if session_uid else None
+    if session_uid:
+        SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke(session_uid)
+    _audit("identity_logout", "success", actor_uid=g.current_user.get("id"),
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid)
+    response = jsonify(success({"revoked": True}))
+    response.delete_cookie("dataops_refresh", path="/api/system/identity")
+    return response
+
+
+@bp.route("/identity/idp-versions", methods=["GET"])
+@require_permissions(IDENTITY_MANAGE)
+def identity_idp_versions():
+    rows = db.session.execute(text("SELECT provider_uid::text,version,status,issuer,client_id,secret_ref,redirect_uris,algorithms,mapping_version,created_at FROM public.identity_idp_config_versions ORDER BY provider_uid,version DESC")).mappings().all()
+    return jsonify(success([{**dict(row), "secret_ref": "env:DATAOPS_OIDC_***"} for row in rows]))
+
+
+@bp.route("/identity/idp-versions", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_idp_version_create():
+    data = _body()
+    provider_uid = _uuid(data["provider_uid"], "provider_uid") if data.get("provider_uid") else new_governance_uid()
+    mapping_version = _required_str(data, "mapping_version", maximum=120)
+    group_rules = data.get("group_rules")
+    _validate_mapping(mapping_version, group_rules)
+    config = IdpConfig(provider_uid=provider_uid, version=_positive_int(data.get("version"), "version"),
+        issuer=_required_str(data, "issuer", maximum=2048), client_id=_required_str(data, "client_id", maximum=300),
+        secret_ref=_required_str(data, "secret_ref", maximum=160),
+        authorization_endpoint=_required_str(data, "authorization_endpoint", maximum=2048),
+        token_endpoint=_required_str(data, "token_endpoint", maximum=2048),
+        jwks_uri=_required_str(data, "jwks_uri", maximum=2048), redirect_uris=_string_array(data, "redirect_uris"),
+        post_login_redirect_uris=_string_array(data, "post_login_redirect_uris"),
+        algorithms=_string_array(data, "algorithms", default=["RS256"]), mapping_version=mapping_version).validate()
+    db.session.execute(text("""
+        INSERT INTO public.identity_idp_config_versions(uid,provider_uid,version,status,issuer,client_id,secret_ref,authorization_endpoint,
+        token_endpoint,jwks_uri,redirect_uris,algorithms,mapping_version,config,created_by)
+        VALUES(CAST(:uid AS uuid),CAST(:provider AS uuid),:version,'draft',:issuer,:client,:secret,:auth,:token,:jwks,
+        CAST(:redirects AS jsonb),CAST(:algorithms AS jsonb),:mapping,CAST(:config AS jsonb),CAST(:actor AS uuid))
+    """), {"uid": new_governance_uid(), "provider": config.provider_uid, "version": config.version, "issuer": config.issuer,
+             "client": config.client_id, "secret": config.secret_ref, "auth": config.authorization_endpoint,
+             "token": config.token_endpoint, "jwks": config.jwks_uri, "redirects": json.dumps(config.redirect_uris),
+             "algorithms": json.dumps(config.algorithms), "mapping": config.mapping_version,
+             "config": json.dumps({"display_name": data.get("display_name"), "group_rules": group_rules,
+                                   "post_login_redirect_uris": data.get("post_login_redirect_uris", [])}), "actor": g.current_user["id"]})
+    db.session.commit()
+    _audit("idp_version_created", "success", actor_uid=g.current_user["id"], provider_uid=config.provider_uid)
+    return jsonify(success(config.public_dict())), 201
+
+
+@bp.route("/identity/idp-versions/<provider_uid>/<int:version>/<action>", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_idp_action(provider_uid: str, version: int, action: str):
+    provider_uid = _uuid(provider_uid, "provider_uid")
+    version = _positive_int(version, "version")
+    row = _load_config(provider_uid, version=version, active=False)
+    if not row:
+        raise IdentityPolicyError("IdP configuration version not found")
+    config = _config(row)
+    config.validate()
+    _validate_mapping(config.mapping_version, dict(row["config"]).get("group_rules"))
+    if action == "dry-run":
+        result = {"valid": True, "secret_available": bool(config.resolve_secret())}
+        _audit("idp_dry_run", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid,
+               resource_uid=f"{provider_uid}:{version}", detail={"version": version})
+        return jsonify(success(result))
+    if action in {"activate", "rotate"}:
+        config.resolve_secret()
+        db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='superseded' WHERE provider_uid=CAST(:p AS uuid) AND status='active'"), {"p": provider_uid})
+        db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='active',activated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid) AND version=:v"), {"p": provider_uid, "v": version})
+        db.session.execute(text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid)"), {"p": provider_uid})
+        db.session.execute(text("""
+            UPDATE public.identity_sessions SET status='revoked',revoke_reason='provider_configuration_changed',revoked_at=CURRENT_TIMESTAMP
+            WHERE status='active' AND identity_link_uid IN
+              (SELECT uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:p AS uuid))
+        """), {"p": provider_uid})
+    elif action == "disable":
+        disabled = db.session.execute(text("UPDATE public.identity_idp_config_versions SET status='disabled',disabled_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid) AND version=:v AND status='active' RETURNING provider_uid"), {"p": provider_uid, "v": version}).scalar_one_or_none()
+        if not disabled:
+            raise IdentityPolicyError("only the selected active IdP version can be disabled")
+        db.session.execute(text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE provider_uid=CAST(:p AS uuid)"), {"p": provider_uid})
+        db.session.execute(text("""
+            UPDATE public.identity_sessions SET status='revoked',revoke_reason='provider_disabled',revoked_at=CURRENT_TIMESTAMP
+            WHERE status='active' AND identity_link_uid IN
+              (SELECT uid FROM public.enterprise_identity_links WHERE provider_uid=CAST(:p AS uuid))
+        """), {"p": provider_uid})
+    elif action == "connectivity":
+        payload = OidcTransport().fetch_jwks(config)
+        result = {"reachable": True, "key_count": len(payload.get("keys", []))}
+        _audit("idp_connectivity", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid,
+               resource_uid=f"{provider_uid}:{version}", detail={"version": version, **result})
+        return jsonify(success(result))
+    else:
+        raise IdentityPolicyError("unsupported IdP version action")
+    db.session.commit()
+    _audit(f"idp_{action}", "success", actor_uid=g.current_user["id"], provider_uid=provider_uid)
+    return jsonify(success({"provider_uid": provider_uid, "version": version, "action": action}))
+
+
+@bp.route("/identity/claims/dry-run", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_claims_dry_run():
+    data = _body()
+    mapping_version = _required_str(data, "mapping_version", maximum=120)
+    claims = data.get("claims")
+    if not isinstance(claims, dict):
+        raise IdentityPolicyError("claims must be an object")
+    mapped = _validate_mapping(mapping_version, data.get("group_rules")).map(claims)
+    result = {"roles": mapped.roles, "business_domain_uids": mapped.business_domain_uids,
+              "object_types": mapped.object_types, "environments": mapped.environments,
+              "data_scopes": mapped.data_scopes, "mapping_version": mapped.mapping_version,
+              "claims_digest": mapped.claims_digest}
+    _audit("claims_dry_run", "success", actor_uid=g.current_user["id"],
+           enterprise_subject=mapped.subject,
+           detail={"mapping_version": mapped.mapping_version, "claims_digest": mapped.claims_digest,
+                   "role_count": len(mapped.roles), "scope_count": sum(len(result[key]) for key in
+                   ("business_domain_uids", "object_types", "environments", "data_scopes"))})
+    return jsonify(success(result))
+
+
+@bp.route("/identity/directory/delta", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_directory_delta():
+    data = _body()
+    event_type = _required_str(data, "event_type", maximum=20).upper()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    params = {
+        "provider_uid": provider_uid,
+        "source": _required_str(data, "source", maximum=80),
+        "source_event_id": _required_str(data, "source_event_id", maximum=300),
+        "cursor": _required_str(data, "cursor", maximum=500),
+        "cursor_sequence": _positive_int(data.get("cursor_sequence"), "cursor_sequence"),
+        "event_type": event_type,
+    }
+    if event_type in {"JOINER", "MOVER", "RESTORE"}:
+        row = _load_config(provider_uid)
+        if not row:
+            raise IdentityPolicyError("active identity provider not found")
+        config = _config(row)
+        mapped = ClaimMapper(
+            version=config.mapping_version,
+            group_rules=dict(row["config"]).get("group_rules", {}),
+        ).map(data.get("claims", {}))
+        params["subject"] = mapped.subject
+        params["attributes"] = {
+            "provider_uid": config.provider_uid,
+            "username": mapped.username,
+            "display_name": mapped.display_name,
+            "department": mapped.department,
+            "groups": mapped.groups,
+            "roles": mapped.roles,
+            "mapping_version": mapped.mapping_version,
+            "claims_digest": mapped.claims_digest,
+            "authorization_scope": {
+                "business_domain_uids": mapped.business_domain_uids,
+                "object_types": mapped.object_types,
+                "environments": mapped.environments,
+                "data_scopes": mapped.data_scopes,
+            },
+        }
+    elif event_type in {"LEAVER", "DISABLE"}:
+        params["subject"] = _required_str(data, "subject", maximum=500)
+        params["attributes"] = {"username": data.get("username"), "roles": []}
+    elif event_type in {"DEPARTMENT", "GROUP"}:
+        if not isinstance(data.get("attributes"), dict):
+            raise IdentityPolicyError("organization attributes must be an object")
+        params["attributes"] = data["attributes"]
+    else:
+        raise IdentityPolicyError("unsupported directory event type")
+    result = DirectorySynchronizer(_repo(), SessionManager(_repo(), secret=current_app.config["SECRET_KEY"])).apply(**params)
+    _audit("directory_delta", "success", actor_uid=g.current_user["id"],
+           provider_uid=provider_uid, resource_uid=params["source_event_id"],
+           enterprise_subject=str(params["subject"]) if params.get("subject") else None,
+           detail={"event_type": event_type, "result": result})
+    return jsonify(success(result))
+
+
+@bp.route("/identity/sessions", methods=["GET"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_sessions():
+    subject = request.args.get("subject")
+    provider_uid = request.args.get("provider_uid")
+    if subject and not provider_uid:
+        raise IdentityPolicyError("provider_uid is required with subject")
+    if provider_uid:
+        provider_uid = _uuid(provider_uid, "provider_uid")
+    if subject and len(subject) > 500:
+        raise IdentityPolicyError("subject is invalid")
+    rows = _repo().list_sessions(provider_uid, subject)
+    visible = ("uid", "user_uid", "identity_source", "status", "created_at", "last_seen_at", "expires_at")
+    return jsonify(success([{key: row.get(key) for key in visible} for row in rows]))
+
+
+@bp.route("/identity/sessions/<session_uid>/revoke", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_revoke(session_uid: str):
+    session_uid = _uuid(session_uid, "session_uid")
+    data = _body()
+    session = _repo().get_session(session_uid)
+    reason = _optional_str(data, "reason", "operator_revoke", maximum=120)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke(session_uid, reason=reason)
+    _audit("identity_session_revoke", "success", actor_uid=g.current_user["id"],
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"session_uid": session_uid, "revoked": True}))
+
+
+@bp.route("/identity/sessions/<session_uid>/risk-terminate", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_risk(session_uid: str):
+    session_uid = _uuid(session_uid, "session_uid")
+    data = _body()
+    session = _repo().get_session(session_uid)
+    reason = _optional_str(data, "reason", "risk_signal", maximum=300)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).risk_revoke(session_uid, reason=reason)
+    _audit("identity_session_risk_terminate", "success", actor_uid=g.current_user["id"],
+           provider_uid=session.get("provider_uid") if session else None, resource_uid=session_uid,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"session_uid": session_uid, "revoked": True, "risk": True}))
+
+
+@bp.route("/identity/emergency/requests", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_emergency_request():
+    data = _body()
+    account_uid = _uuid(data.get("account_uid"), "account_uid")
+    reason = _required_str(data, "reason", maximum=2000)
+    expires_at = _timestamp(data.get("expires_at"), "expires_at")
+    eligible = db.session.execute(text("""
+        SELECT EXISTS(
+          SELECT 1 FROM public.users u JOIN public.user_roles ur ON ur.user_id=u.id
+          JOIN public.roles r ON r.id=ur.role_id
+          WHERE u.id=CAST(:account AS uuid) AND u.status='active' AND r.name='admin'
+            AND NOT EXISTS(SELECT 1 FROM public.enterprise_identity_links l WHERE l.user_uid=u.id)
+        )
+    """), {"account": account_uid}).scalar()
+    record = EmergencyAccess(_repo()).request(requester_uid=g.current_user["id"], account_uid=account_uid,
+        reason=reason, expires_at=expires_at,
+        account_is_local_active_admin=bool(eligible))
+    _audit("emergency_request", "success", actor_uid=g.current_user["id"], resource_uid=record["uid"],
+           detail={"account_uid": record["account_uid"], "expires_at": record["expires_at"].isoformat()})
+    return jsonify(success(record)), 201
+
+
+@bp.route("/identity/emergency/<request_uid>/<action>", methods=["POST"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_emergency_action(request_uid: str, action: str):
+    request_uid = _uuid(request_uid, "request_uid")
+    service = EmergencyAccess(_repo())
+    data = _body()
+    if action == "approve":
+        result = service.approve(request_uid, approver_uid=g.current_user["id"])
+    elif action == "activate":
+        result = service.activate(request_uid)
+    elif action == "review":
+        result = service.review(request_uid, reviewer_uid=g.current_user["id"],
+                                outcome=_required_str(data, "outcome", maximum=2000))
+    elif action == "close":
+        result = service.close(request_uid, actor_uid=g.current_user["id"])
+    else:
+        raise IdentityPolicyError("unsupported emergency action")
+    _audit(f"emergency_{action}", "warning" if action == "activate" else "success", actor_uid=g.current_user["id"], resource_uid=request_uid)
+    return jsonify(success(result))
+
+
+@bp.route("/identity/sessions/revoke-all", methods=["POST"])
+@require_permissions(IDENTITY_OPERATE)
+@_identity_input_guard
+def identity_session_revoke_all():
+    data = _body()
+    provider_uid = _uuid(data.get("provider_uid"), "provider_uid")
+    subject = _required_str(data, "subject", maximum=500)
+    reason = _optional_str(data, "reason", "operator_revoke_all", maximum=120)
+    SessionManager(_repo(), secret=current_app.config["SECRET_KEY"]).revoke_subject(
+        provider_uid, subject, reason=reason
+    )
+    _audit("identity_session_revoke_all", "success", actor_uid=g.current_user["id"],
+           provider_uid=provider_uid, enterprise_subject=subject,
+           detail={"reason_supplied": bool(data.get("reason"))})
+    return jsonify(success({"revoked": True}))
+
+
+@bp.route("/identity/audit", methods=["GET"])
+@require_permissions(IDENTITY_MANAGE)
+@_identity_input_guard
+def identity_audit():
+    rows = db.session.execute(text("SELECT uid::text,event_type,outcome,actor_uid::text,provider_uid::text,resource_type,resource_uid,safe_detail,created_at FROM public.identity_audit_events ORDER BY created_at DESC LIMIT :limit"),
+                              {"limit": _positive_int(request.args.get("limit", 100), "limit", maximum=500)}).mappings().all()
+    return jsonify(success([dict(row) for row in rows]))

+ 299 - 0
app/api/system/governance_audit.py

@@ -0,0 +1,299 @@
+"""Admin-only governance audit and tamper-evident runtime evidence APIs."""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+from datetime import UTC, datetime, timedelta
+
+from flask import current_app, g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.config.config import is_placeholder_env_value
+from app.core.data_source.redaction import sanitize_exception
+from app.core.system.governance_audit import (
+    GovernanceAuditInvalid,
+    GovernanceAuditNotFound,
+    GovernanceAuditService,
+)
+from app.core.system.governance_audit_repository import (
+    SqlAlchemyGovernanceAuditRepository,
+)
+from app.models.result import failed, success
+
+logger = logging.getLogger(__name__)
+
+
+class GovernanceAuditUnavailable(RuntimeError):
+    """Raised when the server cannot safely perform a sealing operation."""
+
+
+def _effective_evidence_secret() -> tuple[str, bool]:
+    dedicated = str(
+        current_app.config.get("AUDIT_EVIDENCE_SECRET") or ""
+    ).strip()
+    dedicated_ready = (
+        len(dedicated.encode("utf-8")) >= 32
+        and not is_placeholder_env_value(dedicated)
+    )
+    if dedicated_ready:
+        return dedicated, True
+    fallback = hashlib.sha256(
+        (
+            "dataops-wp12-local-audit:"
+            + str(current_app.config.get("SECRET_KEY") or "")
+        ).encode("utf-8")
+    ).hexdigest()
+    return fallback, False
+
+
+def _sealing_allowed(dedicated_ready: bool) -> bool:
+    if dedicated_ready:
+        return True
+    if current_app.config.get("TESTING"):
+        return True
+    return (
+        str(current_app.config.get("FLASK_ENV") or "").lower()
+        != "production"
+    )
+
+
+def _require_sealing_key() -> None:
+    _secret, dedicated_ready = _effective_evidence_secret()
+    if not _sealing_allowed(dedicated_ready):
+        raise GovernanceAuditUnavailable(
+            "dedicated audit evidence key is required"
+        )
+
+
+def get_governance_audit_service():
+    secret, _dedicated = _effective_evidence_secret()
+    return GovernanceAuditService(
+        SqlAlchemyGovernanceAuditRepository(db.session),
+        evidence_secret=secret,
+        key_version=str(
+            current_app.config.get("AUDIT_EVIDENCE_KEY_VERSION")
+            or "local-fallback-v1"
+        ),
+    )
+
+
+def get_security_checks():
+    repository = SqlAlchemyGovernanceAuditRepository(db.session)
+    snapshot = repository.security_snapshot()
+    _secret, dedicated_ready = _effective_evidence_secret()
+    credential_count = snapshot["credential_count"]
+    encrypted_count = snapshot["encrypted_credential_count"]
+    plaintext_count = snapshot["plaintext_source_config_count"]
+    return {
+        "seal_ready": _sealing_allowed(dedicated_ready),
+        "production_key_ready": dedicated_ready,
+        "assurance": (
+            "签名封存用于检测篡改,不等于阻止数据库管理员修改;"
+            "生产环境必须使用独立密钥并纳入企业密钥保管与轮换。"
+        ),
+        "checks": [
+            {
+                "code": "datasource_credential_encryption",
+                "name": "数据源凭据密文存储",
+                "status": (
+                    "passed"
+                    if credential_count == encrypted_count
+                    else "failed"
+                ),
+                "observed_count": encrypted_count,
+                "expected_count": credential_count,
+            },
+            {
+                "code": "legacy_plaintext_cleanup",
+                "name": "采集源配置无明文秘密字段",
+                "status": "passed" if plaintext_count == 0 else "failed",
+                "observed_count": plaintext_count,
+                "expected_count": 0,
+            },
+            {
+                "code": "dedicated_evidence_key",
+                "name": "独立审计封存密钥",
+                "status": "passed" if dedicated_ready else "warning",
+                "reason_code": (
+                    "dedicated_key_configured"
+                    if dedicated_ready
+                    else "local_fallback_key_in_use"
+                ),
+            },
+            {
+                "code": "safe_error_boundary",
+                "name": "异常与日志脱敏边界",
+                "status": "passed",
+            },
+            {
+                "code": "security_response_headers",
+                "name": "API 安全响应头",
+                "status": "passed",
+            },
+            {
+                "code": "audit_source_coverage",
+                "name": "十一类关键操作审计源",
+                "status": "passed",
+                "expected_count": 11,
+            },
+        ],
+    }
+
+
+def _timestamp(value, *, default=None):
+    if value in (None, ""):
+        if default is None:
+            raise GovernanceAuditInvalid("timestamp is required")
+        return default
+    try:
+        parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+    except (TypeError, ValueError) as exc:
+        raise GovernanceAuditInvalid("timestamp must be ISO-8601") from exc
+    if parsed.tzinfo is None:
+        raise GovernanceAuditInvalid("timestamp must include a timezone")
+    return parsed.astimezone(UTC)
+
+
+def _window(values):
+    now = datetime.now(UTC)
+    return (
+        _timestamp(
+            values.get("period_start"),
+            default=now - timedelta(days=30),
+        ),
+        _timestamp(values.get("period_end"), default=now),
+    )
+
+
+def _categories(values):
+    if hasattr(values, "getlist"):
+        result = values.getlist("category")
+        if result:
+            return result
+    return values.get("categories") or values.get("category")
+
+
+def _safe_error(error, status=503):
+    logger.error(
+        "governance audit request failed: %s",
+        sanitize_exception(error, limit=300),
+    )
+    return (
+        jsonify(
+            failed(
+                "审计与运行证据暂不可用",
+                code=status,
+                error={"code": "GOVERNANCE_AUDIT_UNAVAILABLE"},
+            )
+        ),
+        status,
+    )
+
+
+def _execute(operation):
+    try:
+        return jsonify(success(operation()))
+    except GovernanceAuditInvalid as exc:
+        return (
+            jsonify(
+                failed(
+                    str(exc),
+                    code=400,
+                    error={"code": "GOVERNANCE_AUDIT_INVALID"},
+                )
+            ),
+            400,
+        )
+    except GovernanceAuditNotFound:
+        return (
+            jsonify(
+                failed(
+                    "审计证据封存不存在",
+                    code=404,
+                    error={"code": "GOVERNANCE_AUDIT_NOT_FOUND"},
+                )
+            ),
+            404,
+        )
+    except GovernanceAuditUnavailable:
+        return _safe_error(
+            GovernanceAuditUnavailable("audit sealing is not configured")
+        )
+    except Exception as exc:
+        db.session.rollback()
+        return _safe_error(exc)
+
+
+@bp.route("/governance-audit/security-checks", methods=["GET"])
+def governance_audit_security_checks():
+    return _execute(get_security_checks)
+
+
+@bp.route("/governance-audit/coverage", methods=["GET"])
+def governance_audit_coverage():
+    def operation():
+        start, end = _window(request.args)
+        return get_governance_audit_service().coverage(
+            period_start=start,
+            period_end=end,
+        )
+
+    return _execute(operation)
+
+
+@bp.route("/governance-audit/events", methods=["GET"])
+def governance_audit_events():
+    def operation():
+        start, end = _window(request.args)
+        return get_governance_audit_service().list_events(
+            period_start=start,
+            period_end=end,
+            categories=_categories(request.args),
+            action=request.args.get("action"),
+            status=request.args.get("status"),
+            page=request.args.get("page", 1),
+            page_size=request.args.get("page_size", 20),
+        )
+
+    return _execute(operation)
+
+
+@bp.route("/governance-audit/seals", methods=["GET"])
+def governance_audit_seals():
+    return _execute(
+        lambda: get_governance_audit_service().list_seals(
+            limit=request.args.get("limit", 50)
+        )
+    )
+
+
+@bp.route("/governance-audit/seals", methods=["POST"])
+def create_governance_audit_seal():
+    def operation():
+        _require_sealing_key()
+        payload = request.get_json(silent=True) or {}
+        start, end = _window(payload)
+        seal = get_governance_audit_service().create_seal(
+            period_start=start,
+            period_end=end,
+            categories=_categories(payload),
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return seal
+
+    return _execute(operation)
+
+
+@bp.route(
+    "/governance-audit/seals/<seal_uid>/verify",
+    methods=["POST"],
+)
+def verify_governance_audit_seal(seal_uid):
+    def operation():
+        _require_sealing_key()
+        return get_governance_audit_service().verify_seal(seal_uid)
+
+    return _execute(operation)

+ 137 - 0
app/api/system/metering_showback_routes.py

@@ -0,0 +1,137 @@
+"""Closed, Showback-only WP12 HTTP boundary; no chargeback endpoint exists."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+from sqlalchemy.exc import SQLAlchemyError
+
+from app.api.system import bp
+from app.core.governance.metering_showback import DatabaseMeteringShowbackService
+from app.core.system.permissions import (
+    METERING_MANAGE,
+    METERING_READ,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+_MAX_BODY_BYTES = 4096
+_EVENT_FIELDS = {"schema_version", "event_uid", "event_kind", "occurred_at", "window_start", "window_end", "quantity", "unit", "idempotency_key", "evidence", "mapping", "correction_of"}
+_REQUIRED_EVENT_FIELDS = _EVENT_FIELDS - {"correction_of"}
+_ALLOCATION_FIELDS = {"schema_version", "rule_uid", "rule_version", "effective_start", "effective_end", "mapping", "allocations"}
+_BUDGET_FIELDS = {"schema_version", "budget_uid", "window", "mapping", "limit_micros", "threshold_micros"}
+
+
+def _service() -> DatabaseMeteringShowbackService:
+    return DatabaseMeteringShowbackService()
+
+
+def _response(value: object, status: int = 200):
+    response = jsonify(success(value, code=status))
+    response.headers["Cache-Control"] = "no-store"
+    return response, status
+
+
+def _failure(status: int = 400):
+    response = jsonify(failed("metering request rejected", code=status))
+    response.headers["Cache-Control"] = "no-store"
+    return response, status
+
+
+def _event_body() -> dict:
+    if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
+        raise ValueError("metering_body_closed")
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict) or set(value) not in (_REQUIRED_EVENT_FIELDS, _EVENT_FIELDS):
+        raise ValueError("metering_body_closed")
+    mapping = value.get("mapping")
+    if not isinstance(mapping, dict) or set(mapping) != {"department", "project", "cost_center"}:
+        raise ValueError("metering_mapping_closed")
+    return value
+
+
+@bp.post("/metering/events")
+@require_permissions(METERING_MANAGE)
+def record_metering_event():
+    """record local metering event"""
+    try:
+        body = _event_body()
+        result = _service().record_for_principal(principal_id=str(g.current_user["id"]), body=body)
+        return _response(result, 201 if not result.get("replay") else 200)
+    except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure()
+
+
+def _control_body(fields: set[str]) -> dict:
+    if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
+        raise ValueError("metering_body_closed")
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict) or set(value) != fields:
+        raise ValueError("metering_body_closed")
+    mapping = value.get("mapping")
+    if not isinstance(mapping, dict) or set(mapping) != {"department", "project", "cost_center"}:
+        raise ValueError("metering_mapping_closed")
+    return value
+
+
+@bp.post("/metering/allocations")
+@require_permissions(METERING_MANAGE)
+def publish_metering_allocation():
+    """publish versioned local allocation rule"""
+    try:
+        return _response(_service().publish_allocation_for_principal(principal_id=str(g.current_user["id"]), body=_control_body(_ALLOCATION_FIELDS)), 201)
+    except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure()
+
+
+@bp.post("/metering/budgets")
+@require_permissions(METERING_MANAGE)
+def evaluate_metering_budget():
+    """persist disabled-provider budget alert evidence"""
+    try:
+        return _response(_service().evaluate_budget_for_principal(principal_id=str(g.current_user["id"]), body=_control_body(_BUDGET_FIELDS)), 201)
+    except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure()
+
+
+def _window() -> str:
+    if set(request.args) != {"window"}:
+        raise ValueError("metering_query_closed")
+    value = request.args.get("window", "")
+    if len(value) != 7 or value[4:5] != "-" or not value.replace("-", "").isdigit():
+        raise ValueError("metering_window_invalid")
+    return value
+
+
+@bp.get("/metering/showback")
+@require_permissions(METERING_READ)
+def get_showback():
+    """get local showback"""
+    try:
+        return _response(_service().showback_for_principal(principal_id=str(g.current_user["id"]), window=_window(), kind="showback"))
+    except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure(403)
+
+
+@bp.get("/metering/reconciliation")
+@require_permissions(METERING_READ)
+def get_metering_reconciliation():
+    """get metering reconciliation"""
+    try:
+        return _response(_service().showback_for_principal(principal_id=str(g.current_user["id"]), window=_window(), kind="reconciliation"))
+    except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure(403)
+
+
+@bp.get("/metering/allocation-replay")
+@require_permissions(METERING_READ)
+def get_metering_allocation_replay():
+    """replay one persisted allocation-rule version"""
+    try:
+        if set(request.args) != {"window", "rule_uid", "rule_version"}:
+            raise ValueError("metering_query_closed")
+        version = request.args.get("rule_version", "")
+        if not version.isdigit():
+            raise ValueError("rule_version_invalid")
+        return _response(_service().allocation_replay_for_principal(principal_id=str(g.current_user["id"]), window=request.args.get("window", ""), rule_uid=request.args.get("rule_uid", ""), rule_version=int(version)))
+    except (KeyError, LookupError, PermissionError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure(403)

+ 140 - 0
app/api/system/plugin_platform_routes.py

@@ -0,0 +1,140 @@
+"""Closed HTTP validation boundary for the inactive WP13 local fixture."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+from sqlalchemy.exc import SQLAlchemyError
+
+from app.api.system import bp
+from app.core.plugins.governance import PluginManifestError, normalize_plugin_manifest
+from app.core.plugins.persistence import (
+    DatabasePluginPlatformService,
+    PluginPersistenceError,
+)
+from app.core.plugins.repository import PluginLifecycleError, PluginPlatform
+from app.core.system.permissions import AGENTS_MANAGE, require_permissions
+from app.models.result import failed, success
+
+_MAX_BODY_BYTES = 8192
+
+
+def _body(required: set[str]) -> dict:
+    if request.content_length is not None and request.content_length > _MAX_BODY_BYTES:
+        raise ValueError("plugin_body_closed")
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict) or set(value) != required:
+        raise ValueError("plugin_body_closed")
+    return value
+
+
+def _response(value: object, status: int = 200):
+    response = jsonify(success(value, code=status))
+    response.headers["Cache-Control"] = "no-store"
+    return response, status
+
+
+def _failure(status: int = 400):
+    response = jsonify(failed("plugin request rejected", code=status))
+    response.headers["Cache-Control"] = "no-store"
+    return response, status
+
+
+@bp.post("/plugins/local-fixture/validate")
+@require_permissions(AGENTS_MANAGE)
+def validate_local_plugin_fixture():
+    """Validate closed metadata only; it never stores or activates a plugin."""
+    try:
+        body = _body({"manifest", "registry_record"})
+        platform = PluginPlatform(now=lambda: 0)
+        result = platform.register(body["manifest"], registry_record=body["registry_record"])
+        return _response({"plugin_uid": result["plugin_uid"], "version": result["version"], "manifest_digest": result["manifest_digest"], "mode": "ENGINEERING_EVIDENCE_ONLY", "activation": "blocked"})
+    except (PluginManifestError, PluginLifecycleError, TypeError, ValueError):
+        return _failure()
+
+
+@bp.post("/plugins/invocations")
+@require_permissions(AGENTS_MANAGE)
+def invoke_plugin_fixture():
+    """Invoke only an approved fixed local fixture through the DB gateway."""
+    try:
+        body = _body({"plugin_uid", "version", "operation", "input_digest", "idempotency_key"})
+        return _response(DatabasePluginPlatformService().invoke(actor_ref=str(g.current_user["id"]), **body), 201)
+    except (PluginPersistenceError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure()
+
+
+@bp.post("/plugins/registry")
+@require_permissions(AGENTS_MANAGE)
+def register_plugin_fixture():
+    try:
+        body = _body({"manifest", "registry_record"})
+        # Validate executable-surface metadata before allocating a DB gateway;
+        # this keeps malformed URL/code/credential bodies out of all backends.
+        normalize_plugin_manifest(body["manifest"])
+        return _response(DatabasePluginPlatformService().register(actor_ref=str(g.current_user["id"]), **body), 201)
+    except (PluginPersistenceError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure()
+
+
+@bp.post("/plugins/<plugin_uid>/<version>/review")
+@require_permissions(AGENTS_MANAGE)
+def review_plugin_fixture(plugin_uid: str, version: str):
+    try:
+        _body(set())
+        return _response(DatabasePluginPlatformService().review(plugin_uid=plugin_uid, version=version, actor_ref=str(g.current_user["id"])))
+    except (PluginPersistenceError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure()
+
+
+@bp.post("/plugins/<plugin_uid>/<version>/approvals")
+@require_permissions(AGENTS_MANAGE)
+def approve_plugin_fixture(plugin_uid: str, version: str):
+    try:
+        body = _body({"approval_action"})
+        return _response(DatabasePluginPlatformService().issue_approval(plugin_uid=plugin_uid, version=version, actor_ref=str(g.current_user["id"]), **body), 201)
+    except (PluginPersistenceError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure()
+
+
+def _transition(plugin_uid: str, version: str, target_state: str):
+    try:
+        body = _body({"approval_uid", "expected_fence", "incident_uid"})
+        return _response(DatabasePluginPlatformService().transition(plugin_uid=plugin_uid, version=version, target_state=target_state, actor_ref=str(g.current_user["id"]), **body))
+    except (PluginPersistenceError, RuntimeError, TypeError, ValueError, SQLAlchemyError):
+        return _failure()
+
+
+@bp.post("/plugins/<plugin_uid>/<version>/canary")
+@require_permissions(AGENTS_MANAGE)
+def canary_plugin_fixture(plugin_uid: str, version: str):
+    return _transition(plugin_uid, version, "canary")
+
+
+@bp.post("/plugins/<plugin_uid>/<version>/activate")
+@require_permissions(AGENTS_MANAGE)
+def activate_plugin_fixture(plugin_uid: str, version: str):
+    return _transition(plugin_uid, version, "active")
+
+
+@bp.post("/plugins/<plugin_uid>/<version>/pause")
+@require_permissions(AGENTS_MANAGE)
+def pause_plugin_fixture(plugin_uid: str, version: str):
+    return _transition(plugin_uid, version, "paused")
+
+
+@bp.post("/plugins/<plugin_uid>/<version>/rollback")
+@require_permissions(AGENTS_MANAGE)
+def rollback_plugin_fixture(plugin_uid: str, version: str):
+    return _transition(plugin_uid, version, "rolled_back")
+
+
+@bp.post("/plugins/<plugin_uid>/<version>/revoke")
+@require_permissions(AGENTS_MANAGE)
+def revoke_plugin_fixture(plugin_uid: str, version: str):
+    return _transition(plugin_uid, version, "revoked")
+
+
+@bp.post("/plugins/<plugin_uid>/<version>/recover")
+@require_permissions(AGENTS_MANAGE)
+def recover_plugin_fixture(plugin_uid: str, version: str):
+    return _transition(plugin_uid, version, "recovery")

+ 329 - 0
app/api/system/responsibilities.py

@@ -0,0 +1,329 @@
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.core.governance.responsibilities import (
+    ResponsibilityConflict,
+    ResponsibilityService,
+    ResponsibilityUserUnavailable,
+    ResponsibilityValidationError,
+    SqlAlchemyResponsibilityRepository,
+)
+from app.core.governance.unified_responsibilities import (
+    UnifiedResponsibilityService,
+)
+from app.core.governance.unified_responsibility_repository import (
+    SqlAlchemyUnifiedResponsibilityRepository,
+)
+from app.core.system.permissions import (
+    RESPONSIBILITIES_MANAGE,
+    RESPONSIBILITIES_READ,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+def _service() -> ResponsibilityService:
+    return ResponsibilityService(SqlAlchemyResponsibilityRepository(db.session))
+
+
+def _unified_service() -> UnifiedResponsibilityService:
+    return UnifiedResponsibilityService(
+        SqlAlchemyUnifiedResponsibilityRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _etag(response, revision: int):
+    response.headers["ETag"] = f'"{int(revision)}"'
+    return response
+
+
+def _expected_revision() -> int:
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw or not raw.isdigit():
+        raise ResponsibilityValidationError("missing valid If-Match revision")
+    return int(raw)
+
+
+def _unified_error(exc: Exception):
+    db.session.rollback()
+    message = str(exc)
+    if "If-Match" in message:
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(message, code=status)), status
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>",
+    methods=["GET"],
+)
+@require_permissions(RESPONSIBILITIES_READ)
+def get_responsibility_matrix(resource_type: str, resource_uid: str):
+    try:
+        result = _service().get(resource_type, resource_uid)
+        return _etag(jsonify(success(result)), result["revision"])
+    except ResponsibilityValidationError as exc:
+        return jsonify(failed(str(exc), code=400)), 400
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>",
+    methods=["PUT"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def replace_responsibility_matrix(resource_type: str, resource_uid: str):
+    try:
+        revision = _expected_revision()
+        body = request.get_json(silent=True) or {}
+        result = _service().replace(
+            resource_type=resource_type,
+            resource_uid=resource_uid,
+            assignments=body.get("assignments"),
+            expected_revision=revision,
+            actor_uid=g.current_user["id"],
+        )
+        db.session.commit()
+        return _etag(jsonify(success(result, "责任矩阵已更新")), result["revision"])
+    except ResponsibilityConflict as exc:
+        db.session.rollback()
+        return jsonify(failed(str(exc), code=409)), 409
+    except ResponsibilityUserUnavailable as exc:
+        db.session.rollback()
+        return jsonify(failed(str(exc), code=422)), 422
+    except ResponsibilityValidationError as exc:
+        db.session.rollback()
+        status = 428 if "If-Match" in str(exc) else 400
+        return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>/resolved",
+    methods=["GET"],
+)
+@require_permissions(RESPONSIBILITIES_READ)
+def resolve_responsibility(resource_type: str, resource_uid: str):
+    try:
+        return jsonify(
+            success(
+                _unified_service().resolve(
+                    resource_type,
+                    resource_uid,
+                    at=request.args.get("at"),
+                )
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/hierarchy/<resource_type>/<resource_uid>",
+    methods=["PUT"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def replace_responsibility_parent(resource_type: str, resource_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _unified_service().set_parent(
+            {
+                **body,
+                "resource_type": resource_type,
+                "resource_uid": resource_uid,
+            },
+            expected_revision=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任继承关系已更新")), result["revision"]
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def list_responsibility_delegations():
+    try:
+        return jsonify(
+            success(
+                _unified_service().list_delegations(
+                    status=request.args.get("status")
+                )
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def create_responsibility_delegation():
+    try:
+        result = _unified_service().create_delegation(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "责任委派已创建", code=201)), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/delegations/<delegation_uid>/revoke",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def revoke_responsibility_delegation(delegation_uid: str):
+    try:
+        result = _unified_service().revoke_delegation(
+            delegation_uid,
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任委派已收回")), result["current_version"]
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/delegations/expire", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def expire_responsibility_delegations():
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _unified_service().expire_delegations(
+                    at=body.get("at"), actor_uid=g.current_user["id"]
+                ),
+                "到期委派已收回",
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/transfers", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def transfer_departing_responsibility():
+    try:
+        result = _unified_service().transfer_departing_user(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "离岗责任已转交", code=201)), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/policies", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def list_responsibility_policies():
+    try:
+        return jsonify(success(_unified_service().list_policies()))
+    except RuntimeError as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/policies", methods=["POST"])
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def create_responsibility_policy():
+    try:
+        result = _unified_service().create_policy(
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略草稿已创建", code=201)),
+            result["current_version"],
+        ), 201
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/policies/<policy_uid>/revisions",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def revise_responsibility_policy(policy_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _unified_service().revise_policy(
+            policy_uid,
+            body.get("definition"),
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略新版本已创建")),
+            result["current_version"],
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/policies/<policy_uid>/publish",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def publish_responsibility_policy(policy_uid: str):
+    try:
+        result = _unified_service().publish_policy(
+            policy_uid,
+            expected_version=_expected_revision(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(
+            jsonify(success(result, "责任策略已发布")),
+            result["current_version"],
+        )
+    except (ValueError, LookupError, RuntimeError, ResponsibilityValidationError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route(
+    "/responsibilities/<resource_type>/<resource_uid>/joint-review/evaluate",
+    methods=["POST"],
+)
+@require_permissions(RESPONSIBILITIES_MANAGE)
+def evaluate_responsibility_joint_review(resource_type: str, resource_uid: str):
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _unified_service().evaluate_joint_review(
+                    resource_type, resource_uid, body.get("decisions")
+                )
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _unified_error(exc)
+
+
+@bp.route("/responsibilities/operations", methods=["GET"])
+@require_permissions(RESPONSIBILITIES_READ)
+def responsibility_operations():
+    try:
+        return jsonify(
+            success(
+                _unified_service().operations(request.args.get("owner_uid"))
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _unified_error(exc)

+ 280 - 0
app/api/system/security_governance.py

@@ -0,0 +1,280 @@
+"""Cross-domain data security and security engineering APIs."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from flask import current_app, g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.core.system.permissions import (
+    SECURITY_GOVERNANCE_MANAGE,
+    SECURITY_GOVERNANCE_OPERATE,
+    SECURITY_GOVERNANCE_READ,
+    require_permissions,
+)
+from app.core.system.security_delivery import HttpsWebhookSyslogTransport
+from app.core.system.security_governance import SecurityGovernanceService
+from app.core.system.security_governance_repository import (
+    SqlAlchemySecurityGovernanceRepository,
+    WorkCenterSecurityApprovalGateway,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    allowlist = {
+        item.strip().lower()
+        for item in str(current_app.config.get("SECURITY_SIEM_HOST_ALLOWLIST") or "").split(",")
+        if item.strip()
+    }
+    return SecurityGovernanceService(
+        SqlAlchemySecurityGovernanceRepository(db.session),
+        approval_gateway=WorkCenterSecurityApprovalGateway(db.session),
+        siem_transport=HttpsWebhookSyslogTransport(),
+        siem_host_allowlist=allowlist,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _version():
+    value = str(request.headers.get("If-Match") or "").strip().removeprefix("W/").strip('"')
+    if not value.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(value)
+
+
+def _etag(response, record):
+    if record.get("current_version"):
+        response.headers["ETag"] = f'"{record["current_version"]}"'
+    return response
+
+
+def _execute(operation, *, created=False, versioned=False):
+    try:
+        result = operation()
+        response = jsonify(success(result, code=201 if created else 200))
+        if versioned and isinstance(result, dict):
+            response = _etag(response, result)
+        return (response, 201) if created else response
+    except Exception as exc:
+        db.session.rollback()
+        if isinstance(exc, LookupError):
+            status = 404
+        elif isinstance(exc, PermissionError):
+            status = 403
+        elif isinstance(exc, RuntimeError):
+            status = 409
+        else:
+            status = 400
+        return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/security-governance/dashboard", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_dashboard():
+    return _execute(_service().dashboard)
+
+
+@bp.route("/security-governance/profiles", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_profiles():
+    return _execute(lambda: _service().list_profiles(business_domain_uid=request.args.get("business_domain_uid")))
+
+
+@bp.route("/security-governance/profiles", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_profile():
+    return _execute(
+        lambda: _service().create_classification_profile(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/scans", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_scans():
+    return _execute(lambda: _service().list_classification_scans(business_domain_uid=request.args.get("business_domain_uid")))
+
+
+@bp.route("/security-governance/scans", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_scan():
+    return _execute(
+        lambda: _service().scan_sensitive_sample(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/findings", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_findings():
+    return _execute(lambda: _service().list_classification_findings(status=request.args.get("status")))
+
+
+@bp.route("/security-governance/findings/<finding_uid>/review", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_review_finding(finding_uid):
+    return _execute(
+        lambda: _service().review_classification_finding(
+            finding_uid, request.get_json(silent=True) or {}, expected_version=_version(), actor_uid=g.current_user["id"]
+        ),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/access-policies", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_access_policies():
+    return _execute(lambda: _service().list_access_policies(business_domain_uid=request.args.get("business_domain_uid"), status=request.args.get("status")))
+
+
+@bp.route("/security-governance/access-policies", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_access_policy():
+    return _execute(
+        lambda: _service().create_access_policy(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/access-decisions", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_access_decisions():
+    return _execute(lambda: _service().list_access_decisions(decision=request.args.get("decision")))
+
+
+@bp.route("/security-governance/access/evaluate", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_evaluate_access():
+    body = request.get_json(silent=True) or {}
+    body["user_uid"] = g.current_user["id"]
+    body["roles"] = list(g.current_user["roles"])
+    return _execute(lambda: _service().evaluate_access(body), created=True)
+
+
+@bp.route("/security-governance/egress", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_egress():
+    return _execute(lambda: _service().list_egress_requests(status=request.args.get("status")))
+
+
+@bp.route("/security-governance/egress", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_egress():
+    return _execute(
+        lambda: _service().submit_egress_request(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/egress/<request_uid>/reconcile", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_reconcile_egress(request_uid):
+    return _execute(
+        lambda: _service().reconcile_egress_request(request_uid, expected_version=_version(), actor_uid=g.current_user["id"]),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/retention", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_retention():
+    return _execute(_service().list_retention_policies)
+
+
+@bp.route("/security-governance/retention", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_create_retention():
+    return _execute(
+        lambda: _service().create_retention_policy(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/retention/candidates", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_retention_candidates():
+    value = request.args.get("as_of") or datetime.now(UTC).isoformat()
+    return _execute(lambda: _service().retention_candidates(as_of=value, limit=int(request.args.get("limit", 100))))
+
+
+@bp.route("/security-governance/siem/sinks", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_list_siem_sinks():
+    return _execute(_service().list_siem_sinks)
+
+
+@bp.route("/security-governance/siem/sinks", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_create_siem_sink():
+    return _execute(
+        lambda: _service().create_siem_sink(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/siem/deliveries", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_siem_deliveries():
+    return _execute(lambda: _service().list_siem_deliveries(sink_uid=request.args.get("sink_uid")))
+
+
+@bp.route("/security-governance/siem/sinks/<sink_uid>/dispatch", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_siem_dispatch(sink_uid):
+    return _execute(lambda: _service().dispatch_siem_events(sink_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/sboms", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_sboms():
+    return _execute(_service().list_sboms)
+
+
+@bp.route("/security-governance/sboms", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_register_sbom():
+    return _execute(lambda: _service().register_sbom(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/sboms/<sbom_uid>/vulnerabilities", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_import_vulnerabilities(sbom_uid):
+    return _execute(lambda: _service().ingest_vulnerabilities(sbom_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/vulnerabilities", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_vulnerabilities():
+    return _execute(lambda: _service().list_vulnerabilities(status=request.args.get("status"), severity=request.args.get("severity")))
+
+
+def _vulnerability_change(finding_uid, action):
+    service = _service()
+    method = getattr(service, f"{action}_vulnerability")
+    return _execute(
+        lambda: method(finding_uid, request.get_json(silent=True) or {}, expected_version=_version(), actor_uid=g.current_user["id"]),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/assign", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_assign_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "assign")
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/resolve", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_resolve_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "resolve")
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/close", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_close_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "close")

+ 207 - 0
app/api/system/tenant_routes.py

@@ -0,0 +1,207 @@
+"""Authenticated, fail-closed HTTP entry points for the conditional WP10 base."""
+
+from __future__ import annotations
+
+import os
+
+from flask import g, jsonify, request
+
+from app.api.system import bp
+from app.core.system.permissions import (
+    IDENTITY_MANAGE,
+    IDENTITY_OPERATE,
+    IDENTITY_READ,
+    require_permissions,
+)
+from app.core.system.tenant_control_repository import SqlAlchemyTenantControlRepository
+from app.core.system.tenant_control_service import (
+    TenantControlError,
+    TenantControlService,
+)
+from app.models.result import failed, success
+
+_MAX_REQUEST_BYTES = 4096
+_QUOTA_FIELDS = {"quota_name", "amount", "idempotency_key"}
+_LIFECYCLE_FIELDS = {"expected_fence", "idempotency_key", "approval_ref", "backup_digest", "retention_seconds"}
+_APPROVAL_FIELDS = {"operation", "expected_fence", "idempotency_key", "backup_digest", "retention_seconds"}
+
+
+def _service() -> TenantControlService:
+    return TenantControlService(SqlAlchemyTenantControlRepository())
+
+
+def _body(allowed: set[str] = _QUOTA_FIELDS) -> dict:
+    if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES:
+        raise TenantControlError("tenant_payload_closed")
+    if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES:
+        raise TenantControlError("tenant_payload_closed")
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict) or set(value) != allowed:
+        raise TenantControlError("tenant_payload_closed")
+    return value
+
+
+def _server_host() -> str:
+    # Deliberately never consume a client-supplied tenant field or a forwarded
+    # host header.  Trusted proxy normalization belongs in deployment; the
+    # persistent DB membership is the final principal+route authorization.
+    host = os.environ.get("TRUSTED_TENANT_ROUTE_HOST", "").lower()
+    if not host:
+        raise TenantControlError("tenant_route_denied")
+    return host
+
+
+@bp.post("/tenant/quota-claims")
+@require_permissions(IDENTITY_OPERATE)
+def issue_tenant_quota_claim():
+    try:
+        value = _service().issue_quota_claim(
+            principal_id=str(g.current_user["id"]),
+            host=_server_host(),
+            request=_body(),
+        )
+        response = jsonify(success(value, code=201))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 201
+    except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
+        response = jsonify(failed("tenant control request rejected", code=400))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 400
+
+
+@bp.post("/tenant/provisions")
+@require_permissions(IDENTITY_MANAGE)
+def provision_private_tenant():
+    try:
+        value = _service().provision_private(
+            principal_id=str(g.current_user["id"]), host=_server_host(), request=_body_provision()
+        )
+        response = jsonify(success(value, code=201))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 201
+    except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
+        response = jsonify(failed("tenant control request rejected", code=400))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 400
+
+
+def _body_provision() -> dict:
+    return _body({"idempotency_key"})
+
+
+@bp.post("/tenant/approvals")
+@require_permissions(IDENTITY_MANAGE)
+def issue_tenant_lifecycle_approval():
+    try:
+        value = _service().issue_lifecycle_approval(
+            reviewer_id=str(g.current_user["id"]), request=_body_approval()
+        )
+        response = jsonify(success(value, code=201))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 201
+    except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
+        response = jsonify(failed("tenant approval request rejected", code=400))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 400
+
+
+def _body_approval() -> dict:
+    if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES:
+        raise TenantControlError("tenant_payload_closed")
+    if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES:
+        raise TenantControlError("tenant_payload_closed")
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict) or not set(value).issubset(_APPROVAL_FIELDS) or not {"operation", "expected_fence", "idempotency_key"}.issubset(value):
+        raise TenantControlError("tenant_payload_closed")
+    return value
+
+
+def _lifecycle(operation: str):
+    try:
+        value = _service().lifecycle_transition(
+            principal_id=str(g.current_user["id"]), host=_server_host(), operation=operation, request=_body_lifecycle()
+        )
+        response = jsonify(success(value))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 200
+    except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
+        response = jsonify(failed("tenant lifecycle request rejected", code=400))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 400
+
+
+@bp.post("/tenant/lifecycle/activate")
+@require_permissions(IDENTITY_MANAGE)
+def activate_tenant_lifecycle():
+    return _lifecycle("activate")
+
+
+@bp.post("/tenant/lifecycle/freeze")
+@require_permissions(IDENTITY_MANAGE)
+def freeze_tenant_lifecycle():
+    return _lifecycle("freeze")
+
+
+@bp.post("/tenant/lifecycle/recover")
+@require_permissions(IDENTITY_MANAGE)
+def recover_tenant_lifecycle():
+    return _lifecycle("begin_recovery")
+
+
+@bp.post("/tenant/lifecycle/deletion-candidate")
+@require_permissions(IDENTITY_MANAGE)
+def mark_tenant_deletion_candidate():
+    return _lifecycle("mark_deletion_candidate")
+
+
+@bp.post("/tenant/lifecycle/rollback")
+@require_permissions(IDENTITY_MANAGE)
+def rollback_tenant_lifecycle():
+    return _lifecycle("rollback")
+
+
+@bp.post("/tenant/lifecycle/delete")
+@require_permissions(IDENTITY_MANAGE)
+def delete_tenant_lifecycle():
+    return _lifecycle("delete")
+
+
+@bp.get("/tenant/status")
+@require_permissions(IDENTITY_READ)
+def get_tenant_status():
+    return _read("status")
+
+
+@bp.get("/tenant/audit")
+@require_permissions(IDENTITY_READ)
+def get_tenant_audit():
+    return _read("audit")
+
+
+@bp.get("/tenant/manifests")
+@require_permissions(IDENTITY_READ)
+def get_tenant_manifests():
+    return _read("manifests")
+
+
+def _read(kind: str):
+    try:
+        value = _service().read(principal_id=str(g.current_user["id"]), host=_server_host(), kind=kind)
+        response = jsonify(success(value))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 200
+    except (KeyError, RuntimeError, TypeError, ValueError, TenantControlError):
+        response = jsonify(failed("tenant read request rejected", code=400))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 400
+
+
+def _body_lifecycle() -> dict:
+    if request.content_length is not None and request.content_length > _MAX_REQUEST_BYTES:
+        raise TenantControlError("tenant_payload_closed")
+    if len(request.get_data(cache=True)) > _MAX_REQUEST_BYTES:
+        raise TenantControlError("tenant_payload_closed")
+    value = request.get_json(silent=True)
+    if not isinstance(value, dict) or not set(value).issubset(_LIFECYCLE_FIELDS) or not {"expected_fence", "idempotency_key"}.issubset(value):
+        raise TenantControlError("tenant_payload_closed")
+    return value

+ 182 - 0
app/api/system/trusted_delivery.py

@@ -0,0 +1,182 @@
+"""No-store HTTP boundary for closed trusted-delivery contracts."""
+from datetime import UTC, datetime
+
+from flask import g, jsonify, request
+from sqlalchemy.exc import DBAPIError, IntegrityError
+
+from app import db
+from app.api.system import bp
+from app.core.system.permissions import (
+    SECURITY_GOVERNANCE_MANAGE,
+    SECURITY_GOVERNANCE_OPERATE,
+    require_permissions,
+)
+from app.core.system.trusted_delivery import TrustedDeliveryService
+from app.core.system.trusted_delivery_repository import (
+    SqlAlchemyTrustedDeliveryRepository,
+)
+from app.core.system.trusted_delivery_subscription_repository import (
+    SqlAlchemyTrustedDeliverySubscriptionRepository,
+)
+from app.core.system.trusted_delivery_subscriptions import (
+    TrustedDeliverySubscriptionService,
+)
+from app.models.result import failed, success
+
+MAX_TRUSTED_DELIVERY_REQUEST_BYTES = 16 * 1024
+
+
+def _service():
+    return TrustedDeliveryService(SqlAlchemyTrustedDeliveryRepository(db.session))
+
+
+def _subscription_service():
+    return TrustedDeliverySubscriptionService(
+        SqlAlchemyTrustedDeliverySubscriptionRepository(db.session)
+    )
+
+
+def _execute(operation, *, created=False):
+    try:
+        value = operation()
+        # Service/repository methods deliberately leave transaction ownership to
+        # the HTTP boundary.  Commit before returning success so a second
+        # connection can observe a successful response immediately.
+        db.session.commit()
+        response = jsonify(success(value, code=201 if created else 200))
+        response.headers["Cache-Control"] = "no-store"
+        return (response, 201) if created else response
+    except PermissionError:
+        db.session.rollback()
+        response = jsonify(failed("trusted delivery request denied", code=403))
+    except (IntegrityError, DBAPIError):
+        db.session.rollback()
+        response = jsonify(failed("trusted delivery request conflict", code=409))
+    except (LookupError, RuntimeError, ValueError, TypeError):
+        db.session.rollback()
+        response = jsonify(failed("trusted delivery request rejected", code=400))
+    response.headers["Cache-Control"] = "no-store"
+    return response, response.get_json()["code"]
+
+
+def _body(allowed):
+    if request.content_length is not None and request.content_length > MAX_TRUSTED_DELIVERY_REQUEST_BYTES:
+        raise ValueError("trusted delivery request is too large")
+    if len(request.get_data(cache=True)) > MAX_TRUSTED_DELIVERY_REQUEST_BYTES:
+        raise ValueError("trusted delivery request is too large")
+    body = request.get_json(silent=True)
+    if not isinstance(body, dict) or set(body) - allowed:
+        raise ValueError("closed request schema")
+    return body
+
+
+@bp.post("/trusted-delivery/policy-versions")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def create_trusted_delivery_policy():
+    return _execute(lambda: _service().create_policy_version(_body({"code", "version", "selector", "resource_rules"}), actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.post("/trusted-delivery/policy-versions/activate")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def activate_trusted_delivery_policy():
+    return _execute(lambda: _service().activate_policy_version(_body({"code", "version", "approval_ref", "approval_digest", "idempotency_key"}), actor_uid=g.current_user["id"]))
+
+
+@bp.post("/trusted-delivery/policy-versions/rollback")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def rollback_trusted_delivery_policy():
+    return _execute(lambda: _service().rollback_policy_version(_body({"code", "version", "approval_ref", "approval_digest", "idempotency_key"}), actor_uid=g.current_user["id"]))
+
+
+@bp.post("/trusted-delivery/decisions")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def evaluate_trusted_delivery():
+    allowed = {"subject_uid", "roles", "business_domain_uid", "asset_uid", "purpose", "environment", "action", "classification", "requested_fields"}
+    body = _body(allowed)
+    body["subject_uid"] = g.current_user["id"]
+    body["roles"] = list(g.current_user["roles"])
+    return _execute(lambda: _service().evaluate_gateway(body))
+
+
+@bp.post("/trusted-delivery/provisions")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def provision_trusted_delivery():
+    allowed = {"policy_uid", "approval_ref", "approval_digest", "asset_uid", "business_domain_uid", "purpose", "environment", "action", "classification", "requested_fields", "expires_at", "target_capability", "provider", "idempotency_key"}
+    def operation():
+        body = _body(allowed)
+        # Browser callers never choose the identity facts used for policy matching.
+        body["subject_uid"] = g.current_user["id"]
+        return _service().provision(body, actor_uid=g.current_user["id"], subject_roles=list(g.current_user["roles"]))
+    return _execute(operation, created=True)
+
+
+@bp.post("/trusted-delivery/legal-holds")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def create_trusted_delivery_hold():
+    return _execute(lambda: _service().create_legal_hold(_body({"asset_uid", "operation", "approver_refs", "evidence_digest"}), actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.post("/trusted-delivery/legal-holds/release")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def release_trusted_delivery_hold():
+    return _execute(lambda: _service().release_legal_hold(_body({"hold_uid", "approval_ref", "approval_digest", "idempotency_key"}), actor_uid=g.current_user["id"]))
+
+
+@bp.post("/trusted-delivery/reclaim/preview")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def trusted_delivery_reclaim_preview():
+    body = _body({"as_of"})
+    as_of = body.get("as_of") or datetime.now(UTC).isoformat()
+    return _execute(lambda: {"candidates": _service().preview_reclaim(as_of=as_of, actor_uid=g.current_user["id"]), "executed": False})
+
+
+@bp.post("/trusted-delivery/reclaim/execute")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def trusted_delivery_reclaim_execute():
+    return _execute(lambda: _service().execute_reclaim(_body({"grant_uid", "idempotency_key"}), actor_uid=g.current_user["id"]))
+
+
+@bp.post("/trusted-delivery/subscriptions")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def create_trusted_delivery_subscription():
+    allowed = {"grant_uid", "asset_uid", "trigger", "purpose", "expires_at", "idempotency_key"}
+    return _execute(lambda: _subscription_service().create_subscription(_body(allowed), actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.post("/trusted-delivery/subscriptions/<subscription_uid>/pause")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def pause_trusted_delivery_subscription(subscription_uid):
+    return _execute(lambda: _subscription_service().pause_subscription(subscription_uid, actor_uid=g.current_user["id"]))
+
+
+@bp.post("/trusted-delivery/subscriptions/<subscription_uid>/activate")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def activate_trusted_delivery_subscription(subscription_uid):
+    return _execute(lambda: _subscription_service().activate_subscription(subscription_uid, actor_uid=g.current_user["id"]))
+
+
+@bp.post("/trusted-delivery/subscriptions/<subscription_uid>/resume")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def resume_trusted_delivery_subscription(subscription_uid):
+    return _execute(lambda: _subscription_service().resume_subscription(subscription_uid, actor_uid=g.current_user["id"]))
+
+
+@bp.post("/trusted-delivery/subscriptions/<subscription_uid>/terminate")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def terminate_trusted_delivery_subscription(subscription_uid):
+    return _execute(lambda: _subscription_service().terminate_subscription(subscription_uid, actor_uid=g.current_user["id"]))
+
+
+@bp.post("/trusted-delivery/subscriptions/deliveries/<delivery_uid>/compensate")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def compensate_trusted_delivery_subscription_delivery(delivery_uid):
+    return _execute(lambda: _subscription_service().compensate_dead_letter(
+        delivery_uid, **_body({"reason_code", "receipt_code"}), actor_uid=g.current_user["id"]
+    ))
+
+
+@bp.post("/trusted-delivery/subscriptions/anomalies")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def report_trusted_delivery_subscription_anomaly():
+    allowed = {"kind", "asset_uid", "subscription_uid", "incident_uid", "summary", "correlation_id"}
+    return _execute(lambda: _subscription_service().report_anomaly(_body(allowed), actor_uid=g.current_user["id"]), created=True)

+ 99 - 0
app/api/system/trusted_delivery_controls.py

@@ -0,0 +1,99 @@
+"""No-store HTTP boundary for trusted-delivery control and release gate summaries."""
+
+from flask import g, jsonify, request
+from sqlalchemy.exc import DBAPIError, IntegrityError
+
+from app import db
+from app.api.system import bp
+from app.core.system.permissions import (
+    SECURITY_GOVERNANCE_MANAGE,
+    SECURITY_GOVERNANCE_OPERATE,
+    SECURITY_GOVERNANCE_READ,
+    require_permissions,
+)
+from app.core.system.trusted_delivery_controls import TrustedDeliveryControlError
+from app.core.system.trusted_delivery_controls_repository import (
+    SqlAlchemyTrustedDeliveryControlsRepository,
+)
+from app.core.system.trusted_delivery_controls_service import (
+    TrustedDeliveryControlsService,
+)
+from app.models.result import failed, success
+
+MAX_TRUSTED_DELIVERY_CONTROL_REQUEST_BYTES = 16 * 1024
+
+
+def _service():
+    return TrustedDeliveryControlsService(SqlAlchemyTrustedDeliveryControlsRepository(db.session))
+
+
+def _body(allowed):
+    if request.content_length is not None and request.content_length > MAX_TRUSTED_DELIVERY_CONTROL_REQUEST_BYTES:
+        raise TrustedDeliveryControlError("trusted delivery control request is too large")
+    if len(request.get_data(cache=True)) > MAX_TRUSTED_DELIVERY_CONTROL_REQUEST_BYTES:
+        raise TrustedDeliveryControlError("trusted delivery control request is too large")
+    body = request.get_json(silent=True)
+    if not isinstance(body, dict) or set(body) - allowed:
+        raise TrustedDeliveryControlError("closed request schema")
+    return body
+
+
+def _execute(operation, *, created=False):
+    try:
+        value = operation()
+        # Do not acknowledge a durable control mutation before its commit has
+        # succeeded.  Failure takes the common rollback/redacted-error path.
+        db.session.commit()
+        response = jsonify(success(value, code=201 if created else 200))
+        response.headers["Cache-Control"] = "no-store"
+        return (response, 201) if created else response
+    except PermissionError:
+        db.session.rollback()
+        response = jsonify(failed("trusted delivery control request denied", code=403))
+    except (LookupError, RuntimeError, TrustedDeliveryControlError, ValueError, TypeError, IntegrityError, DBAPIError):
+        db.session.rollback()
+        response = jsonify(failed("trusted delivery control request rejected", code=400))
+    response.headers["Cache-Control"] = "no-store"
+    return response, response.get_json()["code"]
+
+
+@bp.get("/trusted-delivery/controls/profiles/<profile_id>/active")
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def trusted_delivery_active_control_profile(profile_id):
+    return _execute(lambda: _service().active_profile_summary(profile_id))
+
+
+@bp.post("/trusted-delivery/controls/release-gates/evaluate")
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def trusted_delivery_release_gate():
+    return _execute(lambda: _service().evaluate_release_gate(_body({"artifact_digest", "sbom_digest", "license_policy_digest", "vulnerability_scan_digest", "approval_id", "approval_version", "scan_decision", "evidence_expires_at", "approval_ref", "approval_digest", "idempotency_key"}), actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.post("/trusted-delivery/controls/profiles/activate")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def trusted_delivery_activate_control_profile():
+    return _execute(lambda: _service().activate_profile(_body({"profile_id", "version", "approval_ref", "approval_digest", "idempotency_key"}), actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.post("/trusted-delivery/controls/profiles/rollback")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def trusted_delivery_rollback_control_profile():
+    return _execute(lambda: _service().activate_profile(_body({"profile_id", "version", "approval_ref", "approval_digest", "idempotency_key"}), actor_uid=g.current_user["id"], operation="rollback"))
+
+
+@bp.post("/trusted-delivery/controls/capability-approvals")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def trusted_delivery_capability_approval():
+    return _execute(lambda: _service().approve_capability(_body({"provider", "capability", "version", "config_digest", "approval_ref", "approval_digest", "idempotency_key"}), actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.get("/trusted-delivery/controls/evidence")
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def trusted_delivery_control_evidence():
+    return _execute(lambda: _service().evidence_summary())
+
+
+@bp.post("/trusted-delivery/controls/destruction-approvals")
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def trusted_delivery_destruction_approval():
+    return _execute(lambda: _service().approve_destruction(_body({"asset_uid", "approval_refs", "evidence_digest", "idempotency_key"}), actor_uid=g.current_user["id"]), created=True)

+ 95 - 8
app/api/system/users.py

@@ -1,8 +1,10 @@
 from __future__ import annotations
 
+import json
+
 from flask import g, jsonify, request
 from sqlalchemy import text
-from sqlalchemy.exc import IntegrityError
+from sqlalchemy.exc import IntegrityError, ProgrammingError
 
 from app import db
 from app.api.system import bp
@@ -12,10 +14,56 @@ from app.core.system.auth import hash_password
 from app.core.system.permissions import MANAGE_USERS, require_permissions
 from app.models.result import failed, success
 
-
 VALID_ROLES = {"admin", "editor", "viewer"}
 
 
+def _audit_access_control(action, resource_uid, status, safe_detail):
+    db.session.execute(
+        text(
+            "INSERT INTO public.access_control_audit_events "
+            "(uid,action,actor_uid,resource_type,resource_uid,status,safe_detail) VALUES "
+            "(CAST(:uid AS uuid),:action,CAST(:actor AS uuid),'user',:resource_uid,:status,CAST(:detail AS jsonb))"
+        ),
+        {
+            "uid": new_governance_uid(),
+            "action": action,
+            "actor": g.current_user["id"],
+            "resource_uid": resource_uid,
+            "status": status,
+            "detail": json.dumps(safe_detail, ensure_ascii=False),
+        },
+    )
+    try:
+        with db.session.begin_nested():
+            db.session.execute(
+                text(
+                    "INSERT INTO public.identity_audit_events "
+                    "(uid,event_type,outcome,actor_uid,resource_type,resource_uid,safe_detail) VALUES "
+                    "(CAST(:uid AS uuid),:action,:outcome,CAST(:actor AS uuid),'user',:resource_uid,CAST(:detail AS jsonb))"
+                ),
+                {"uid": new_governance_uid(), "action": action, "outcome": "success" if status == "success" else "failure",
+                 "actor": g.current_user["id"], "resource_uid": resource_uid,
+                 "detail": json.dumps(safe_detail, ensure_ascii=False)},
+            )
+    except ProgrammingError:
+        pass
+
+
+def _revoke_identity_sessions(user_id: str, reason: str) -> None:
+    try:
+        with db.session.begin_nested():
+            db.session.execute(
+                text("UPDATE public.enterprise_identity_links SET token_version=token_version+1,updated_at=CURRENT_TIMESTAMP WHERE user_uid=CAST(:id AS uuid)"),
+                {"id": user_id},
+            )
+            db.session.execute(
+                text("UPDATE public.identity_sessions SET status='revoked',revoke_reason=:reason,revoked_at=CURRENT_TIMESTAMP WHERE user_uid=CAST(:id AS uuid) AND status='active'"),
+                {"id": user_id, "reason": reason},
+            )
+    except ProgrammingError:
+        pass
+
+
 def _active_admin_count(session) -> int:
     return int(
         session.execute(
@@ -109,6 +157,9 @@ def create_user():
             ),
             {"user_id": user_id, "assigned_by": g.current_user["id"], "roles": sorted(roles)},
         )
+        _audit_access_control(
+            "user_created", user_id, "success", {"roles": sorted(roles)}
+        )
         db.session.commit()
         return jsonify(success({"id": user_id}, "用户创建成功", code=201)), 201
     except (ValueError, IntegrityError) as exc:
@@ -123,9 +174,12 @@ def update_user(user_id: str):
     status = body.get("status")
     if status not in (None, "active", "disabled"):
         return jsonify(failed("用户状态无效", code=400)), 400
-    if status == "disabled" and _is_active_admin(db.session, user_id):
-        if _active_admin_count(db.session) <= 1:
-            return jsonify(failed("不能停用最后一个有效管理员", code=409)), 409
+    if (
+        status == "disabled"
+        and _is_active_admin(db.session, user_id)
+        and _active_admin_count(db.session) <= 1
+    ):
+        return jsonify(failed("不能停用最后一个有效管理员", code=409)), 409
     values = {"id": user_id}
     assignments = []
     if status is not None:
@@ -150,6 +204,19 @@ def update_user(user_id: str):
     if not result.rowcount:
         db.session.rollback()
         return jsonify(failed("用户不存在", code=404)), 404
+    _audit_access_control(
+        "user_updated",
+        user_id,
+        "success",
+        {
+            "changed_fields": sorted(
+                key for key in ("status", "display_name", "password") if key in body
+            ),
+            "password_value_retained": False,
+        },
+    )
+    if status == "disabled":
+        _revoke_identity_sessions(user_id, "account_disabled")
     db.session.commit()
     return jsonify(success(message="用户更新成功"))
 
@@ -161,9 +228,21 @@ def update_user_roles(user_id: str):
     roles = set(body.get("roles") or [])
     if not roles or not roles <= VALID_ROLES:
         return jsonify(failed("角色无效", code=400)), 400
-    if _is_active_admin(db.session, user_id) and "admin" not in roles:
-        if _active_admin_count(db.session) <= 1:
-            return jsonify(failed("不能移除最后一个有效管理员角色", code=409)), 409
+    if (
+        _is_active_admin(db.session, user_id)
+        and "admin" not in roles
+        and _active_admin_count(db.session) <= 1
+    ):
+        return jsonify(failed("不能移除最后一个有效管理员角色", code=409)), 409
+    previous_roles = list(
+        db.session.execute(
+            text(
+                "SELECT r.name FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id "
+                "WHERE ur.user_id=CAST(:id AS uuid) ORDER BY r.name"
+            ),
+            {"id": user_id},
+        ).scalars()
+    )
     db.session.execute(
         text("DELETE FROM public.user_roles WHERE user_id = CAST(:id AS uuid)"),
         {"id": user_id},
@@ -179,5 +258,13 @@ def update_user_roles(user_id: str):
     if result.rowcount != len(roles):
         db.session.rollback()
         return jsonify(failed("用户或角色不存在", code=404)), 404
+    _audit_access_control(
+        "user_roles_updated",
+        user_id,
+        "success",
+        {"before_roles": previous_roles, "after_roles": sorted(roles)},
+    )
+    if sorted(previous_roles) != sorted(roles):
+        _revoke_identity_sessions(user_id, "roles_changed")
     db.session.commit()
     return jsonify(success(message="角色更新成功"))

+ 327 - 0
app/api/system/work_center.py

@@ -0,0 +1,327 @@
+"""Unified governance work-center API."""
+
+from __future__ import annotations
+
+from flask import g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.core.events.email_delivery import smtp_sender
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+from app.core.system.permissions import (
+    WORK_CENTER_MANAGE,
+    WORK_CENTER_OPERATE,
+    WORK_CENTER_READ,
+    permissions_for_roles,
+    require_permissions,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    return UnifiedWorkCenterService(
+        SqlAlchemyWorkCenterRepository(db.session),
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _expected_version() -> int:
+    raw = str(request.headers.get("If-Match") or "").strip()
+    if raw.startswith("W/"):
+        raw = raw[2:].strip()
+    raw = raw.strip('"')
+    if not raw.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(raw)
+
+
+def _etag(response, version):
+    response.headers["ETag"] = f'"{int(version)}"'
+    return response
+
+
+def _error(exc):
+    db.session.rollback()
+    if "If-Match" in str(exc):
+        status = 428
+    elif isinstance(exc, LookupError):
+        status = 404
+    elif isinstance(exc, PermissionError):
+        status = 403
+    elif isinstance(exc, RuntimeError):
+        status = 409
+    else:
+        status = 400
+    return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/work-center/workflows", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_workflows():
+    return jsonify(success(_service().list_workflows()))
+
+
+@bp.route("/work-center/workflows", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def create_work_center_workflow():
+    try:
+        result = _service().create_workflow(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "流程草稿已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/workflows/<workflow_uid>/revisions", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def revise_work_center_workflow(workflow_uid):
+    try:
+        result = _service().revise_workflow(
+            workflow_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "流程版本已创建")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/workflows/<workflow_uid>/publish", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def publish_work_center_workflow(workflow_uid):
+    try:
+        result = _service().publish_workflow(
+            workflow_uid,
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "流程版本已发布")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_tasks():
+    permissions = permissions_for_roles(g.current_user.get("roles", []))
+    filters = {
+        key: request.args.get(key)
+        for key in ("status", "task_type", "subject_type", "assignee_uid")
+        if request.args.get(key)
+    }
+    filters.update(
+        {
+            "requester_uid": g.current_user["id"],
+            "can_manage": WORK_CENTER_MANAGE in permissions,
+        }
+    )
+    return jsonify(success(_service().list_tasks(**filters)))
+
+
+@bp.route("/work-center/tasks", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def create_work_center_task():
+    try:
+        result = _service().create_task(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "统一任务已创建", code=201)), result["current_version"]), 201
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_task(task_uid):
+    try:
+        permissions = permissions_for_roles(g.current_user.get("roles", []))
+        result = _service().task_detail(
+            task_uid,
+            requester_uid=g.current_user["id"],
+            can_manage=WORK_CENTER_MANAGE in permissions,
+        )
+        return _etag(jsonify(success(result)), result["current_version"])
+    except (ValueError, LookupError) as exc:
+        return _error(exc)
+
+
+def _task_action(task_uid, action):
+    service = _service()
+    body = request.get_json(silent=True) or {}
+    arguments = {
+        "expected_version": _expected_version(),
+        "actor_uid": g.current_user["id"],
+    }
+    if action == "review":
+        return service.review_task(task_uid, body, **arguments)
+    if action == "transfer":
+        return service.transfer_review(task_uid, body, **arguments)
+    if action == "close":
+        return service.close_task(task_uid, body, **arguments)
+    return service.reopen_task(task_uid, body, **arguments)
+
+
+@bp.route("/work-center/tasks/<task_uid>/<action>", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def operate_work_center_task(task_uid, action):
+    if action not in {"review", "transfer", "close", "reopen"}:
+        return jsonify(failed("unsupported task action", code=404)), 404
+    try:
+        result = _task_action(task_uid, action)
+        return _etag(jsonify(success(result, "任务状态已更新")), result["current_version"])
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>/comments", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def add_work_center_comment(task_uid):
+    try:
+        result = _service().add_comment(
+            task_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "评论已添加", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/tasks/<task_uid>/attachments", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def add_work_center_attachment(task_uid):
+    try:
+        result = _service().add_attachment(
+            task_uid,
+            request.get_json(silent=True) or {},
+            actor_uid=g.current_user["id"],
+        )
+        return jsonify(success(result, "附件证据已登记", code=201)), 201
+    except (ValueError, LookupError, PermissionError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/timeouts/process", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def process_work_center_timeouts():
+    try:
+        body = request.get_json(silent=True) or {}
+        return jsonify(
+            success(
+                _service().process_timeouts(
+                    at=body.get("at"), actor_uid=g.current_user["id"]
+                ),
+                "逾期任务已处理",
+            )
+        )
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/notifications", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_notifications():
+    return jsonify(
+        success(
+            _service().list_notifications(
+                g.current_user["id"],
+                unread_only=request.args.get("unread_only") == "true",
+            )
+        )
+    )
+
+
+@bp.route("/work-center/notifications/<notification_uid>/read", methods=["POST"])
+@require_permissions(WORK_CENTER_OPERATE)
+def read_work_center_notification(notification_uid):
+    try:
+        return jsonify(
+            success(
+                _service().mark_notification_read(
+                    notification_uid, actor_uid=g.current_user["id"]
+                ),
+                "消息已读",
+            )
+        )
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/notifications/deliver", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def deliver_work_center_notifications():
+    try:
+        body = request.get_json(silent=True) or {}
+        result = _service().deliver_notifications(
+            "email", smtp_sender, at=body.get("at"), limit=body.get("limit", 50)
+        )
+        return jsonify(success(result, "邮件投递批次已处理"))
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/templates", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def list_work_center_templates():
+    return jsonify(success(_service().list_notification_templates()))
+
+
+@bp.route("/work-center/templates", methods=["POST"])
+@require_permissions(WORK_CENTER_MANAGE)
+def create_work_center_template():
+    try:
+        result = _service().create_notification_template(
+            request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]
+        )
+        return _etag(jsonify(success(result, "通知模板已创建", code=201)), 1), 201
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/templates/<template_uid>", methods=["PATCH"])
+@require_permissions(WORK_CENTER_MANAGE)
+def revise_work_center_template(template_uid):
+    try:
+        result = _service().revise_notification_template(
+            template_uid,
+            request.get_json(silent=True) or {},
+            expected_version=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "通知模板已更新")), result["current_version"])
+    except (ValueError, LookupError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/preferences", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_preferences():
+    result = _service().get_notification_preferences(g.current_user["id"])
+    return _etag(jsonify(success(result)), result["revision"])
+
+
+@bp.route("/work-center/preferences", methods=["PUT"])
+@require_permissions(WORK_CENTER_OPERATE)
+def replace_work_center_preferences():
+    try:
+        result = _service().replace_notification_preferences(
+            request.get_json(silent=True) or {},
+            expected_revision=_expected_version(),
+            actor_uid=g.current_user["id"],
+        )
+        return _etag(jsonify(success(result, "通知偏好已更新")), result["revision"])
+    except (ValueError, RuntimeError) as exc:
+        return _error(exc)
+
+
+@bp.route("/work-center/dashboard", methods=["GET"])
+@require_permissions(WORK_CENTER_READ)
+def get_work_center_dashboard():
+    try:
+        return jsonify(success(_service().dashboard(at=request.args.get("at"))))
+    except ValueError as exc:
+        return _error(exc)

+ 34 - 7
app/commands/process_outbox.py

@@ -2,7 +2,8 @@ from __future__ import annotations
 
 import argparse
 import logging
-from typing import Any, Callable
+from collections.abc import Callable
+from typing import Any
 
 from app import create_app, db
 from app.core.events.consumer import dispatch_event
@@ -13,10 +14,34 @@ from app.core.events.outbox import (
     reset_stale_processing,
 )
 
-
 logger = logging.getLogger(__name__)
 
 
+def _missing_handler(event_type):
+    def handler(_payload):
+        raise ValueError(f"no handler registered for {event_type}")
+
+    return handler
+
+
+def build_governance_knowledge_handlers(app, session):
+    from app.core.data_research.semantic_knowledge import (
+        build_governance_knowledge_handlers as build_handlers,
+    )
+
+    return build_handlers(app, session)
+
+
+def build_default_handlers(app, session):
+    handlers = build_governance_knowledge_handlers(app, session)
+    if not handlers:
+        logger.warning(
+            "Governance knowledge sync is not configured; "
+            "publication events will remain pending"
+        )
+    return handlers
+
+
 def process_available_events(
     session: Any,
     handlers: dict[str, Callable[[dict[str, Any]], None]],
@@ -29,13 +54,15 @@ def process_available_events(
         logger.info("No outbox handlers registered; leaving events pending")
         return 0
 
-    events = claim_outbox(session, limit=batch_size)
+    events = claim_outbox(
+        session,
+        limit=batch_size,
+        event_types=sorted(handlers),
+    )
     for event in events:
         handler = handlers.get(event.event_type)
         if handler is None:
-            handler = lambda _payload: (_ for _ in ()).throw(
-                ValueError(f"no handler registered for {event.event_type}")
-            )
+            handler = _missing_handler(event.event_type)
         result = dispatch_event(
             event,
             handler,
@@ -65,7 +92,7 @@ def main() -> None:
         )
         processed = process_available_events(
             db.session,
-            handlers={},
+            handlers=build_default_handlers(app, db.session),
             batch_size=args.batch_size,
         )
         logger.info("Processed %s outbox events", processed)

+ 26 - 0
app/commands/reconcile_data_research.py

@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+import argparse
+import json
+
+from app import create_app
+
+
+def run(reconciler, *, repair=False):
+    return reconciler.reconcile(repair=repair)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="Reconcile data-research control and projection stores")
+    parser.add_argument("--repair", action="store_true", help="repair rebuildable projections")
+    args = parser.parse_args()
+    app = create_app()
+    with app.app_context():
+        factory = app.extensions.get("data_research_reconciler_factory")
+        if factory is None:
+            raise RuntimeError("data-research reconciliation adapters are not configured")
+        print(json.dumps(run(factory(), repair=args.repair), ensure_ascii=False, sort_keys=True))
+
+
+if __name__ == "__main__":
+    main()

+ 37 - 0
app/commands/reconcile_dataflow_creates.py

@@ -0,0 +1,37 @@
+"""Inspect or repair orphaned governed DataFlow create Sagas."""
+
+from __future__ import annotations
+
+import argparse
+import json
+
+from app import create_app, db
+from app.core.data_flow.create_reconciliation import DataFlowCreateReconciler
+from app.core.data_rules.repository import DataRuleRepository
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="Reconcile database-owned governed DataFlow create intents"
+    )
+    mode = parser.add_mutually_exclusive_group()
+    mode.add_argument("--apply", action="store_true")
+    mode.add_argument("--dry-run", action="store_true")
+    parser.add_argument("--limit", type=int, default=100)
+    parser.add_argument("--lease-seconds", type=int, default=60)
+    args = parser.parse_args()
+
+    app = create_app()
+    with app.app_context():
+        report = DataFlowCreateReconciler(
+            DataRuleRepository(db.session)
+        ).run(
+            dry_run=not args.apply,
+            limit=args.limit,
+            lease_seconds=args.lease_seconds,
+        )
+        print(json.dumps(report, ensure_ascii=False, sort_keys=True))
+
+
+if __name__ == "__main__":
+    main()

+ 200 - 28
app/config/config.py

@@ -1,6 +1,8 @@
 import os
 import platform
-from typing import Mapping
+from collections.abc import Mapping
+
+from app.config.database_urls import validate_postgresql_url
 
 
 def get_bool_env(name: str, default: bool = False) -> bool:
@@ -144,9 +146,7 @@ def is_placeholder_env_value(value: str) -> bool:
         return True
     if "dataops_user@" in lower and "127.0.0.1" in lower:
         return True
-    if lower in {"127.0.0.1:9000", "localhost:9000"}:
-        return True
-    return False
+    return lower in {"127.0.0.1:9000", "localhost:9000"}
 
 
 def _is_local_minio_host(host: str) -> bool:
@@ -162,7 +162,16 @@ def _apply_minio_config(app, defaults: dict) -> None:
     password = _clean_env("MINIO_PASSWORD")
     bucket = _clean_env("MINIO_BUCKET")
     prefix = _clean_env("MINIO_PREFIX")
-    is_production = os.environ.get("FLASK_ENV", "").lower() == "production"
+    is_production = str(app.config.get("FLASK_ENV", "")).lower() == "production"
+
+    if is_production:
+        app.config["MINIO_HOST"] = host
+        app.config["MINIO_USER"] = user
+        app.config["MINIO_PASSWORD"] = password
+        app.config["MINIO_BUCKET"] = bucket
+        app.config["PREFIX"] = prefix
+        app.config["MINIO_SECURE"] = get_bool_env("MINIO_SECURE", False)
+        return
 
     use_defaults = (
         is_placeholder_env_value(user)
@@ -203,6 +212,36 @@ PRODUCTION_SERVICE_DEFAULTS = {
     "MINIO_PREFIX": "",
 }
 
+_PRODUCTION_REQUIRED_SERVICE_ENV = (
+    "DATABASE_URL",
+    "BI_AI_CATALOG_CONTROL_DATABASE_URL",
+    "NEO4J_URI",
+    "NEO4J_HTTP_URI",
+    "NEO4J_USER",
+    "NEO4J_PASSWORD",
+    "MINIO_HOST",
+    "MINIO_USER",
+    "MINIO_PASSWORD",
+    "MINIO_BUCKET",
+)
+
+
+def _validate_production_service_env() -> None:
+    """Reject missing, template, or malformed production service settings."""
+    values = {name: _clean_env(name) for name in _PRODUCTION_REQUIRED_SERVICE_ENV}
+    runtime = validate_postgresql_url(values["DATABASE_URL"], "DATABASE_URL")
+    control = validate_postgresql_url(values["BI_AI_CATALOG_CONTROL_DATABASE_URL"], "BI_AI_CATALOG_CONTROL_DATABASE_URL")
+    if control.username != "dataops_bi_ai_catalog_control" or control.username == runtime.username:
+        raise RuntimeError("production BI_AI_CATALOG_CONTROL_DATABASE_URL must use the dedicated control identity")
+    for name, value in values.items():
+        if name in {"DATABASE_URL", "BI_AI_CATALOG_CONTROL_DATABASE_URL"}:
+            continue
+        placeholder = is_placeholder_env_value(value)
+        if name in {"NEO4J_URI", "NEO4J_HTTP_URI", "MINIO_HOST"}:
+            placeholder = not value or value.lower().startswith("replace-")
+        if placeholder:
+            raise RuntimeError(f"production {name} is required and cannot be a placeholder")
+
 
 def _apply_config_from_env(app, config_key: str, env_name: str, default):
     value = _clean_env(env_name)
@@ -218,21 +257,35 @@ def apply_runtime_env_config(app) -> None:
     load_production_env_file()
     defaults = PRODUCTION_SERVICE_DEFAULTS
 
-    _apply_config_from_env(
-        app, "SQLALCHEMY_DATABASE_URI", "DATABASE_URL", defaults["SQLALCHEMY_DATABASE_URI"]
-    )
-    _apply_config_from_env(app, "NEO4J_URI", "NEO4J_URI", defaults["NEO4J_URI"])
-    _apply_config_from_env(
-        app, "NEO4J_HTTP_URI", "NEO4J_HTTP_URI", defaults["NEO4J_HTTP_URI"]
-    )
-    _apply_config_from_env(app, "NEO4J_USER", "NEO4J_USER", defaults["NEO4J_USER"])
-    _apply_config_from_env(
-        app, "NEO4J_PASSWORD", "NEO4J_PASSWORD", defaults["NEO4J_PASSWORD"]
-    )
-    app.config["NEO4J_ENCRYPTED"] = get_bool_env(
-        "NEO4J_ENCRYPTED", defaults["NEO4J_ENCRYPTED"]
-    )
-    _apply_minio_config(app, defaults)
+    is_production = str(app.config.get("FLASK_ENV", "")).lower() == "production"
+    if is_production:
+        _validate_production_service_env()
+        app.config["SQLALCHEMY_DATABASE_URI"] = _clean_env("DATABASE_URL")
+        for config_key, env_name in (
+            ("NEO4J_URI", "NEO4J_URI"),
+            ("NEO4J_HTTP_URI", "NEO4J_HTTP_URI"),
+            ("NEO4J_USER", "NEO4J_USER"),
+            ("NEO4J_PASSWORD", "NEO4J_PASSWORD"),
+        ):
+            app.config[config_key] = _clean_env(env_name)
+        app.config["NEO4J_ENCRYPTED"] = get_bool_env("NEO4J_ENCRYPTED", True)
+        _apply_minio_config(app, defaults)
+    else:
+        _apply_config_from_env(
+            app, "SQLALCHEMY_DATABASE_URI", "DATABASE_URL", defaults["SQLALCHEMY_DATABASE_URI"]
+        )
+        _apply_config_from_env(app, "NEO4J_URI", "NEO4J_URI", defaults["NEO4J_URI"])
+        _apply_config_from_env(
+            app, "NEO4J_HTTP_URI", "NEO4J_HTTP_URI", defaults["NEO4J_HTTP_URI"]
+        )
+        _apply_config_from_env(app, "NEO4J_USER", "NEO4J_USER", defaults["NEO4J_USER"])
+        _apply_config_from_env(
+            app, "NEO4J_PASSWORD", "NEO4J_PASSWORD", defaults["NEO4J_PASSWORD"]
+        )
+        app.config["NEO4J_ENCRYPTED"] = get_bool_env(
+            "NEO4J_ENCRYPTED", defaults["NEO4J_ENCRYPTED"]
+        )
+        _apply_minio_config(app, defaults)
 
     secret_key = _clean_env("SECRET_KEY")
     if secret_key and not is_placeholder_env_value(secret_key):
@@ -281,10 +334,78 @@ def apply_runtime_env_config(app) -> None:
                 "DATASOURCE_CERT_DIR",
                 "/etc/dataops-platform/datasource-certs",
             ),
+            "AUDIT_EVIDENCE_SECRET": _clean_env(
+                "AUDIT_EVIDENCE_SECRET"
+            ),
+            "AUDIT_EVIDENCE_KEY_VERSION": _clean_env(
+                "AUDIT_EVIDENCE_KEY_VERSION", "local-fallback-v1"
+            ),
+            "SECURITY_SIEM_HOST_ALLOWLIST": _clean_env(
+                "SECURITY_SIEM_HOST_ALLOWLIST"
+            ),
+            "AGENT_CREDENTIAL_SECRET": _clean_env("AGENT_CREDENTIAL_SECRET"),
         }
     )
 
 
+def validate_production_database_identity(app, engine) -> None:
+    """Verify the connected production login has runtime-only database powers."""
+    if str(app.config.get("FLASK_ENV", "")).lower() != "production":
+        return
+    from sqlalchemy import text
+
+    try:
+        with engine.connect() as connection:
+            identity = connection.execute(text("""
+                SELECT current_user AS role_name,
+                       role.rolsuper,role.rolcreaterole,role.rolcreatedb,
+                       role.rolreplication,role.rolbypassrls,
+                       EXISTS (
+                         SELECT 1 FROM pg_class object
+                         JOIN pg_namespace namespace ON namespace.oid=object.relnamespace
+                         WHERE object.relowner=role.oid
+                           AND namespace.nspname NOT IN ('pg_catalog','information_schema')
+                           AND namespace.nspname NOT LIKE 'pg_toast%'
+                       ) OR EXISTS (
+                         SELECT 1 FROM pg_proc function
+                         JOIN pg_namespace namespace ON namespace.oid=function.pronamespace
+                         WHERE function.proowner=role.oid
+                           AND namespace.nspname NOT IN ('pg_catalog','information_schema')
+                       ) OR EXISTS (
+                         SELECT 1 FROM pg_namespace namespace
+                         WHERE namespace.nspowner=role.oid
+                           AND namespace.nspname NOT IN ('pg_catalog','information_schema')
+                           AND namespace.nspname NOT LIKE 'pg_%'
+                       ) AS owns_application_objects,
+                       EXISTS (
+                         SELECT 1 FROM pg_roles evidence_owner
+                         WHERE evidence_owner.rolname='dataops_edge_evidence_owner'
+                           AND pg_has_role(current_user,evidence_owner.oid,'MEMBER')
+                       ) AS evidence_owner_member,
+                       EXISTS (
+                         SELECT 1 FROM pg_roles trusted_owner
+                         WHERE trusted_owner.rolname IN (
+                           'dataops_trusted_delivery_owner',
+                           'dataops_trusted_delivery_writer'
+                         )
+                           AND pg_has_role(current_user,trusted_owner.oid,'MEMBER')
+                       ) AS trusted_delivery_owner_member
+                  FROM pg_roles role WHERE role.rolname=current_user
+            """)).mappings().one()
+    except Exception as exc:
+        raise RuntimeError("production runtime database identity verification failed") from exc
+    forbidden = (
+        identity["rolsuper"] or identity["rolcreaterole"] or identity["rolcreatedb"]
+        or identity["rolreplication"] or identity["rolbypassrls"]
+        or identity["owns_application_objects"] or identity["evidence_owner_member"]
+        or identity["trusted_delivery_owner_member"]
+    )
+    if forbidden:
+        raise RuntimeError(
+            "production DATABASE_URL must use a non-owner, least-privilege runtime identity"
+        )
+
+
 def log_llm_env_status(app) -> None:
     """Log LLM env load result after logging is configured."""
     deepseek_key = _clean_env("DEEPSEEK_API_KEY") or _clean_env("LLM_API_KEY")
@@ -327,6 +448,8 @@ def log_service_env_status(app) -> None:
         app.config.get("MINIO_HOST"),
         _mask_secret(str(app.config.get("MINIO_USER", ""))),
     )
+    if str(app.config.get("FLASK_ENV", "")).lower() == "production":
+        return
     if is_placeholder_env_value(_clean_env("DATABASE_URL")):
         app.logger.warning(
             "DATABASE_URL 仍为模板占位符,已回退到默认生产库 "
@@ -364,6 +487,9 @@ class BaseConfig:
     """基础配置类,包含所有环境共享的配置"""
 
     SECRET_KEY = os.environ.get("SECRET_KEY") or "you-will-never-guess"
+    RULE_GENERATION_RECEIPT_SECRET = os.environ.get(
+        "RULE_GENERATION_RECEIPT_SECRET"
+    )
     JSON_AS_ASCII = False
     JSONIFY_PRETTYPRINT_REGULAR = True
     JSON_SORT_KEYS = False
@@ -387,6 +513,15 @@ class BaseConfig:
         "docx",
         "doc",
     }
+    DATA_RESEARCH_FILE_MAX_BYTES = int(
+        os.environ.get("DATA_RESEARCH_FILE_MAX_BYTES", str(25 * 1024 * 1024))
+    )
+    DATA_RESEARCH_FILE_MAX_PAGES = int(
+        os.environ.get("DATA_RESEARCH_FILE_MAX_PAGES", "500")
+    )
+    DATA_RESEARCH_MAX_CONCURRENT_JOBS = int(
+        os.environ.get("DATA_RESEARCH_MAX_CONCURRENT_JOBS", "10")
+    )
 
     # 数据抽取配置
     DATA_EXTRACT_BATCH_SIZE = 1000  # 每批处理的记录数
@@ -413,6 +548,24 @@ class BaseConfig:
     DATASOURCE_CREDENTIAL_KEY_VERSION = os.environ.get(
         "DATASOURCE_CREDENTIAL_KEY_VERSION", "v1"
     )
+    AGENT_CREDENTIAL_SECRET = os.environ.get("AGENT_CREDENTIAL_SECRET", "")
+    # Ed25519 raw-private-key hex or PEM. It is loaded only into process memory
+    # and must never be persisted in a task, release, audit, or HTTP response.
+    EDGE_GATEWAY_SIGNING_PRIVATE_KEY = os.environ.get(
+        "EDGE_GATEWAY_SIGNING_PRIVATE_KEY", ""
+    )
+    EDGE_GATEWAY_SIGNING_PRIVATE_KEY_FILE = os.environ.get(
+        "EDGE_GATEWAY_SIGNING_PRIVATE_KEY_FILE", ""
+    )
+    EDGE_GATEWAY_SIGNING_PUBLIC_KEY = os.environ.get(
+        "EDGE_GATEWAY_SIGNING_PUBLIC_KEY", ""
+    )
+    EDGE_GATEWAY_SIGNING_KEY_ID = os.environ.get(
+        "EDGE_GATEWAY_SIGNING_KEY_ID", ""
+    )
+    EDGE_MTLS_TRUSTED_PROXY_IPS = os.environ.get(
+        "EDGE_MTLS_TRUSTED_PROXY_IPS", ""
+    )
     DATASOURCE_CERT_DIR = os.environ.get(
         "DATASOURCE_CERT_DIR",
         "/etc/dataops-platform/datasource-certs",
@@ -466,6 +619,27 @@ class BaseConfig:
     N8N_API_KEY = os.environ.get("N8N_API_KEY", "")
     N8N_API_TIMEOUT = int(os.environ.get("N8N_API_TIMEOUT", "30"))
 
+    # Kestra-backed governed Data Factory. Activation remains fail-closed
+    # unless the operator explicitly opens the post-acceptance gate.
+    KESTRA_BASE_URL = os.environ.get(
+        "KESTRA_BASE_URL", "http://127.0.0.1:18080/api/v1"
+    )
+    KESTRA_TENANT_ID = os.environ.get("KESTRA_TENANT_ID", "main")
+    KESTRA_USERNAME = os.environ.get("KESTRA_USERNAME", "")
+    KESTRA_PASSWORD = os.environ.get("KESTRA_PASSWORD", "")
+    KESTRA_HTTP_TIMEOUT_SECONDS = int(
+        os.environ.get("KESTRA_HTTP_TIMEOUT_SECONDS", "30")
+    )
+    RUNNER_TASK_TOKEN_SECRET = os.environ.get(
+        "RUNNER_TASK_TOKEN_SECRET", ""
+    )
+    DATA_FACTORY_ACTIVATION_ENABLED = get_bool_env(
+        "DATA_FACTORY_ACTIVATION_ENABLED", False
+    )
+    DEVICE_ENTITY_AUTO_MERGE_ENABLED = get_bool_env(
+        "DEVICE_ENTITY_AUTO_MERGE_ENABLED", False
+    )
+
     # DataOps 平台 API 基础 URL(用于 n8n 工作流回调等)
     API_BASE_URL = os.environ.get("API_BASE_URL", "http://127.0.0.1:5500/api")
 
@@ -513,23 +687,21 @@ class ProductionConfig(BaseConfig):
     PORT = int(os.environ.get("LISTEN_PORT", os.environ.get("PORT", "5500")))
 
     # 生产环境 MinIO 配置
-    MINIO_HOST = os.environ.get("MINIO_HOST", "127.0.0.1:9000")
-    MINIO_USER = os.environ.get("MINIO_USER", "citu-test")
-    MINIO_PASSWORD = os.environ.get("MINIO_PASSWORD", "citu-test")
+    MINIO_HOST = os.environ.get("MINIO_HOST", "")
+    MINIO_USER = os.environ.get("MINIO_USER", "")
+    MINIO_PASSWORD = os.environ.get("MINIO_PASSWORD", "")
     MINIO_SECURE = get_bool_env("MINIO_SECURE", False)
     MINIO_BUCKET = os.environ.get("MINIO_BUCKET", "dataops-bucket")
     PREFIX = os.environ.get("MINIO_PREFIX", "")
 
     # 生产环境 PostgreSQL 配置
-    SQLALCHEMY_DATABASE_URI = os.environ.get(
-        "DATABASE_URL", "postgresql://postgres:postgres@127.0.0.1:5432/dataops"
-    )
+    SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "")
 
     # 生产环境 Neo4j 配置
     NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://127.0.0.1:7687")
     NEO4J_HTTP_URI = os.environ.get("NEO4J_HTTP_URI", "http://127.0.0.1:7474")
-    NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
-    NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "Passw0rd")
+    NEO4J_USER = os.environ.get("NEO4J_USER", "")
+    NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "")
     NEO4J_ENCRYPTED = get_bool_env("NEO4J_ENCRYPTED", False)
 
     # 生产环境文件路径配置

+ 121 - 0
app/config/database_urls.py

@@ -0,0 +1,121 @@
+"""Fail-closed validation for production PostgreSQL identities and templates."""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+from urllib.parse import unquote, urlsplit
+
+_BAD_PERCENT_ESCAPE = re.compile(r"%(?![0-9a-fA-F]{2})")
+_PLACEHOLDER_VALUES = {
+    "change-me",
+    "changeme",
+    "database",
+    "database_name",
+    "dbname",
+    "host",
+    "hostname",
+    "pass",
+    "password",
+    "replace",
+    "user",
+    "username",
+}
+
+
+@dataclass(frozen=True)
+class DatabaseUrlParts:
+    username: str
+    password: str
+    host: str
+    database: str
+
+
+def is_template_secret(value: str) -> bool:
+    """Return true only for empty or explicit template values, after decoding."""
+    normalized = unquote(str(value or "")).strip().lower()
+    return (
+        not normalized
+        or normalized in _PLACEHOLDER_VALUES
+        or normalized.startswith(("replace-", "your-"))
+        or "${" in normalized
+        or normalized.startswith("<")
+        or normalized.endswith(">")
+    )
+
+
+def validate_postgresql_url(value: str, field_name: str) -> DatabaseUrlParts:
+    """Parse a PostgreSQL URL and reject empty/template identity components."""
+    raw = str(value or "").strip()
+    if not raw or _BAD_PERCENT_ESCAPE.search(raw):
+        raise RuntimeError(f"{field_name} is missing or malformed")
+    try:
+        parsed = urlsplit(raw)
+        port = parsed.port
+    except (TypeError, ValueError) as exc:
+        raise RuntimeError(f"{field_name} is malformed") from exc
+    if parsed.scheme.split("+", 1)[0].lower() not in {"postgres", "postgresql"}:
+        raise RuntimeError(f"{field_name} must use PostgreSQL")
+    values = {
+        "username": unquote(parsed.username or ""),
+        "password": unquote(parsed.password or ""),
+        "host": unquote(parsed.hostname or ""),
+        "database": unquote(parsed.path.lstrip("/")),
+    }
+    if port is not None and not 1 <= port <= 65535:
+        raise RuntimeError(f"{field_name} has an invalid port")
+    for component, component_value in values.items():
+        if is_template_secret(component_value):
+            raise RuntimeError(f"{field_name} has an invalid {component}")
+    return DatabaseUrlParts(**values)
+
+
+def _read_env_file(path: str | Path) -> dict[str, str]:
+    values: dict[str, str] = {}
+    for raw_line in Path(path).read_text(encoding="utf-8-sig").splitlines():
+        line = raw_line.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        name, value = line.split("=", 1)
+        values[name.strip()] = value.strip().strip('"').strip("'")
+    return values
+
+
+def validate_database_environment(values: dict[str, str]) -> None:
+    runtime_parts = None
+    for field_name in (
+        "DB_ROLE_INIT_DATABASE_URL",
+        "MIGRATION_DATABASE_URL",
+        "DATABASE_URL",
+    ):
+        parts = validate_postgresql_url(values.get(field_name, ""), field_name)
+        if field_name == "DATABASE_URL":
+            runtime_parts = parts
+    runtime_password = values.get("DATAOPS_RUNTIME_PASSWORD", "")
+    if is_template_secret(runtime_password):
+        raise RuntimeError("DATAOPS_RUNTIME_PASSWORD is missing or a template value")
+    if runtime_parts is None or runtime_parts.password != runtime_password:
+        raise RuntimeError("DATAOPS_RUNTIME_PASSWORD must match DATABASE_URL")
+
+
+def load_and_validate_database_env(path: str | Path) -> None:
+    validate_database_environment(_read_env_file(path))
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--env-file", required=True)
+    args = parser.parse_args(argv)
+    try:
+        load_and_validate_database_env(args.env_file)
+    except (OSError, RuntimeError) as exc:
+        print(f"database configuration rejected: {exc}", file=sys.stderr)
+        return 2
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 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"]

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

@@ -0,0 +1,32 @@
+"""Built-in connectors registered exclusively through the public SDK."""
+
+from app.core.connectors.builtin.oracle import OracleConnector
+from app.core.connectors.builtin.postgresql import PostgreSQLConnector
+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(PostgreSQLConnector(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",
+    "PostgreSQLConnector",
+    "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",
+        )

+ 68 - 0
app/core/connectors/builtin/postgresql.py

@@ -0,0 +1,68 @@
+"""PostgreSQL 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,
+)
+
+POSTGRESQL_CATALOG_SQL = """
+SELECT c.table_schema AS schema_name, c.table_name AS asset_name,
+       CASE WHEN t.table_type = 'VIEW' THEN 'VIEW' ELSE 'TABLE' END AS asset_type,
+       c.column_name, c.ordinal_position, c.data_type, c.is_nullable,
+       c.column_default, d.description AS column_comment
+FROM information_schema.columns c
+JOIN information_schema.tables t
+  ON t.table_schema = c.table_schema AND t.table_name = c.table_name
+LEFT JOIN pg_catalog.pg_namespace n ON n.nspname = c.table_schema
+LEFT JOIN pg_catalog.pg_class r
+  ON r.relnamespace = n.oid AND r.relname = c.table_name
+LEFT JOIN pg_catalog.pg_description d
+  ON d.objoid = r.oid AND d.objsubid = c.ordinal_position
+WHERE t.table_type IN ('BASE TABLE', 'VIEW')
+  AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
+ORDER BY c.table_schema, c.table_name, c.ordinal_position
+"""
+
+
+class PostgreSQLConnector(ReadOnlyCatalogConnector):
+    catalog_sql = POSTGRESQL_CATALOG_SQL
+    manifest = ConnectorManifest(
+        connector_id="postgresql",
+        version="1.0.0",
+        sdk_version=SDK_VERSION,
+        display_name="PostgreSQL",
+        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("psycopg2") is not None
+        return HealthResult(
+            "available" if available else "degraded",
+            "" if available else "optional driver unavailable",
+        )
+
+    def compatibility(self):
+        available = importlib.util.find_spec("psycopg2") 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"]

+ 144 - 0
app/core/data_flow/create_reconciliation.py

@@ -0,0 +1,144 @@
+"""Recover governed DataFlow creates from database-owned canonical intents."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from app.core.data_flow.dataflows import DataFlowService
+
+
+class DataFlowCreateReconciler:
+    """Lease, isolate, and reconcile orphaned PostgreSQL/Neo4j creates."""
+
+    def __init__(self, repository):
+        self.repository = repository
+
+    def run(
+        self,
+        *,
+        dry_run: bool = True,
+        limit: int = 100,
+        lease_seconds: int = 60,
+    ) -> dict[str, Any]:
+        if dry_run:
+            candidates = self.repository.list_reconcilable_dataflow_creates(
+                limit=limit
+            )
+            return {
+                "dry_run": True,
+                "candidate_count": len(candidates),
+                "reconciled_count": 0,
+                "failed_count": 0,
+                "items": candidates,
+            }
+
+        items = []
+        for _ in range(limit):
+            claims = self.repository.claim_reconcilable_dataflow_creates(
+                limit=1, lease_seconds=lease_seconds
+            )
+            if not claims:
+                self.repository.session.rollback()
+                break
+            claim = claims[0]
+            self.repository.commit_dataflow_create_claim()
+            base = {
+                "reservation_id": claim["reservation_id"],
+                "dataflow_uid": claim["dataflow_uid"],
+                "attempt": claim["attempt"],
+                "request_digest": claim["request_digest"],
+            }
+            if not claim["integrity_valid"]:
+                try:
+                    self.repository.commit_dataflow_create_failure(
+                        reservation_id=claim["reservation_id"],
+                        lease_token=claim["lease_token"],
+                        error_code="create_intent_integrity_failed",
+                    )
+                    status = "failed"
+                    error_code = "create_intent_integrity_failed"
+                except Exception:
+                    self.repository.session.rollback()
+                    status = "lease_recovery_required"
+                    error_code = "failure_state_persistence_failed"
+                items.append(
+                    {**base, "status": status, "error_code": error_code}
+                )
+                continue
+            try:
+                intent = DataFlowService.validate_governed_create_intent(
+                    claim["create_intent"], repository=self.repository
+                )
+            except (TypeError, ValueError):
+                self.repository.session.rollback()
+                try:
+                    self.repository.commit_dataflow_create_failure(
+                        reservation_id=claim["reservation_id"],
+                        lease_token=claim["lease_token"],
+                        error_code="create_intent_validation_failed",
+                    )
+                    status = "failed"
+                    error_code = "create_intent_validation_failed"
+                except Exception:
+                    self.repository.session.rollback()
+                    status = "lease_recovery_required"
+                    error_code = "failure_state_persistence_failed"
+                items.append(
+                    {**base, "status": status, "error_code": error_code}
+                )
+                continue
+            try:
+                self.repository.renew_dataflow_create_lease(
+                    reservation_id=claim["reservation_id"],
+                    lease_token=claim["lease_token"],
+                    lease_seconds=lease_seconds,
+                )
+                self.repository.commit_dataflow_create_claim()
+                node_id, result = DataFlowService._merge_governed_dataflow(
+                    intent["node"]
+                )
+                if intent["tags"]:
+                    DataFlowService._handle_tag_relationships(
+                        node_id, intent["tags"], strict=True
+                    )
+                result = self.repository.complete_dataflow_create(
+                    reservation_id=claim["reservation_id"],
+                    dataflow_uid=claim["dataflow_uid"],
+                    lease_token=claim["lease_token"],
+                    request_digest=claim["request_digest"],
+                    dataflow_node_id=node_id,
+                    result=result,
+                )
+                self.repository.session.commit()
+                items.append(
+                    {
+                        **base,
+                        "status": "completed",
+                        "dataflow_node_id": result.get("id"),
+                    }
+                )
+            except Exception:
+                self.repository.session.rollback()
+                try:
+                    self.repository.commit_dataflow_create_failure(
+                        reservation_id=claim["reservation_id"],
+                        lease_token=claim["lease_token"],
+                        error_code="reconciliation_failed",
+                    )
+                    status = "failed"
+                    error_code = "reconciliation_failed"
+                except Exception:
+                    self.repository.session.rollback()
+                    status = "lease_recovery_required"
+                    error_code = "failure_state_persistence_failed"
+                items.append(
+                    {**base, "status": status, "error_code": error_code}
+                )
+        completed = sum(item["status"] == "completed" for item in items)
+        return {
+            "dry_run": False,
+            "candidate_count": len(items),
+            "reconciled_count": completed,
+            "failed_count": len(items) - completed,
+            "items": items,
+        }

+ 542 - 94
app/core/data_flow/dataflows.py

@@ -1,4 +1,5 @@
 import contextlib
+import copy
 import json
 import logging
 import os
@@ -11,6 +12,7 @@ from sqlalchemy import text
 
 from app import db
 from app.core.common.identifiers import ensure_governance_uid
+from app.core.data_rules.contracts import validate_dataflow_spec
 from app.core.data_service.data_product_service import DataProductService
 from app.core.graph.graph_operations import (
     connect_graph,
@@ -29,6 +31,264 @@ PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
 class DataFlowService:
     """数据流服务类,处理数据流相关的业务逻辑"""
 
+    _GOVERNED_REQUIREMENT_KEYS = {
+        "dataflow_spec",
+        "dataset_edges",
+        "migration_metadata",
+    }
+    _MIGRATION_METADATA_KEYS = {
+        "status",
+        "legacy_fields_present",
+        "preserved_for_read_only",
+        "governed_semantics",
+    }
+    _GOVERNED_PAYLOAD_FIELDS = {
+        "draft_reservation",
+        "dataflow_spec",
+        "dataset_edges",
+        "migration_metadata",
+    }
+    _GOVERNED_FORBIDDEN_FIELDS = {
+        "code",
+        "generated_code",
+        "legacy_script_path",
+        "n8n_workflow_id",
+        "task_list",
+        "workflow",
+    }
+    _GOVERNED_NODE_KEYS = {
+        "uid",
+        "name_zh",
+        "name_en",
+        "category",
+        "organization",
+        "leader",
+        "frequency",
+        "describe",
+        "status",
+        "update_mode",
+        "script_type",
+        "script_requirement",
+        "script_path",
+    }
+
+    @staticmethod
+    def _decode_script_requirement(value: Any) -> Any:
+        if not isinstance(value, str):
+            return copy.deepcopy(value)
+        try:
+            return json.loads(value)
+        except (TypeError, json.JSONDecodeError):
+            return value
+
+    @classmethod
+    def _signals_governed(cls, data: dict[str, Any], requirement: Any) -> bool:
+        return (
+            data.get("script_type") == "governed"
+            or bool(set(data) & cls._GOVERNED_PAYLOAD_FIELDS)
+            or (
+                isinstance(requirement, dict)
+                and bool(set(requirement) & cls._GOVERNED_REQUIREMENT_KEYS)
+            )
+        )
+
+    @classmethod
+    def _reject_governed_execution_fields(cls, data: dict[str, Any]) -> None:
+        if set(data) & cls._GOVERNED_FORBIDDEN_FIELDS:
+            raise ValueError(
+                "governed DataFlow cannot contain legacy execution fields"
+            )
+        if data.get("script_type") not in {None, "governed"}:
+            raise ValueError("governed DataFlow script_type must be governed")
+        if data.get("script_path") not in {None, ""}:
+            raise ValueError("governed DataFlow cannot define script_path")
+
+    @classmethod
+    def validate_governed_requirement(
+        cls, value: Any, *, repository
+    ) -> Dict[str, Any]:
+        """Validate the closed, published-asset DataFlow save envelope."""
+        requirement = cls._decode_script_requirement(value)
+        if not isinstance(requirement, dict):
+            raise ValueError("governed script_requirement must be an object")
+        if set(requirement) != cls._GOVERNED_REQUIREMENT_KEYS:
+            raise ValueError(
+                "governed script_requirement contains unsupported fields"
+            )
+
+        flow = validate_dataflow_spec(requirement.get("dataflow_spec"))
+        edges = requirement.get("dataset_edges")
+        if not isinstance(edges, dict) or set(edges) != {
+            "source_table",
+            "target_table",
+        }:
+            raise ValueError("dataset edges must be a closed object")
+        if (
+            edges.get("source_table") != flow["input_schema_refs"]
+            or edges.get("target_table") != flow["output_schema_ref"]
+        ):
+            raise ValueError("dataset edges must match the DataFlow schema refs")
+
+        metadata = requirement.get("migration_metadata")
+        if (
+            not isinstance(metadata, dict)
+            or set(metadata) != cls._MIGRATION_METADATA_KEYS
+        ):
+            raise ValueError("migration metadata must be a closed object")
+        if metadata.get("status") not in {"migrated", "unmigrated"}:
+            raise ValueError("unsupported migration status")
+        if type(metadata.get("legacy_fields_present")) is not bool:
+            raise ValueError("legacy_fields_present must be a boolean")
+        if metadata.get("preserved_for_read_only") is not True:
+            raise ValueError("legacy fields must be preserved for read-only use")
+        if metadata.get("governed_semantics") != "dataflow_spec":
+            raise ValueError("unsupported governed semantics")
+
+        repository.load_published_assets(flow)
+        return {
+            "dataflow_spec": flow,
+            "dataset_edges": {
+                "source_table": list(flow["input_schema_refs"]),
+                "target_table": flow["output_schema_ref"],
+            },
+            "migration_metadata": copy.deepcopy(metadata),
+        }
+
+    @staticmethod
+    def _is_dataflow_constraint_conflict(error: Exception) -> bool:
+        code = str(getattr(error, "code", "") or "")
+        return code in {
+            "Neo.ClientError.Schema.ConstraintCreationFailed",
+            "Neo.ClientError.Schema.ConstraintValidationFailed",
+            "Neo.ClientError.Schema.ConstraintVerificationFailed",
+        }
+
+    @staticmethod
+    def _install_dataflow_constraints(session) -> None:
+        """Install both identity constraints, retrying only schema deadlocks."""
+        for attempt in range(3):
+            try:
+                session.run(
+                    "CREATE CONSTRAINT data_flow_uid IF NOT EXISTS "
+                    "FOR (n:DataFlow) REQUIRE n.uid IS UNIQUE"
+                ).consume()
+                session.run(
+                    "CREATE CONSTRAINT data_flow_name_zh IF NOT EXISTS "
+                    "FOR (n:DataFlow) REQUIRE n.name_zh IS UNIQUE"
+                ).consume()
+                return
+            except Exception as exc:
+                if DataFlowService._is_dataflow_constraint_conflict(exc):
+                    raise ValueError("dataflow_uid_conflict") from exc
+                code = str(getattr(exc, "code", "") or "")
+                if (
+                    code
+                    == "Neo.TransientError.Transaction.DeadlockDetected"
+                    and attempt < 2
+                ):
+                    continue
+                raise
+        raise RuntimeError("DataFlow constraints were not installed")
+
+    @staticmethod
+    def _merge_governed_dataflow(node_data: dict[str, Any]) -> tuple[int, dict]:
+        """Idempotently create by stable UID and reject immutable conflicts."""
+        node_data = copy.deepcopy(node_data)
+        node_data.setdefault("created_at", get_formatted_time())
+        node_data.setdefault("updated_at", node_data["created_at"])
+        immutable = {
+            key: node_data[key]
+            for key in ("uid", "name_zh", "script_type", "script_requirement")
+        }
+        driver = connect_graph()
+        try:
+            with driver.session() as session:
+                DataFlowService._install_dataflow_constraints(session)
+                conflict = session.run(
+                    "MATCH (n:DataFlow {name_zh: $name_zh}) "
+                    "WHERE n.uid IS NULL OR n.uid <> $uid "
+                    "RETURN n.uid AS uid LIMIT 1",
+                    {"name_zh": node_data["name_zh"], "uid": node_data["uid"]},
+                ).single()
+                if conflict is not None:
+                    raise ValueError("dataflow_uid_conflict")
+                try:
+                    record = session.run(
+                        "MERGE (n:DataFlow {uid: $uid}) "
+                        "ON CREATE SET n = $properties "
+                        "RETURN n, id(n) AS node_id",
+                        {"uid": node_data["uid"], "properties": node_data},
+                    ).single()
+                except Exception as exc:
+                    if DataFlowService._is_dataflow_constraint_conflict(exc):
+                        raise ValueError("dataflow_uid_conflict") from exc
+                    raise
+                if record is None:
+                    raise RuntimeError(
+                        "governed DataFlow MERGE returned no node"
+                    )
+                persisted = dict(record["n"])
+                for key, expected in immutable.items():
+                    if persisted.get(key) != expected:
+                        raise ValueError("dataflow_uid_conflict")
+                node_id = record["node_id"]
+                if isinstance(node_id, bool) or not isinstance(node_id, int):
+                    raise RuntimeError("governed DataFlow node id is invalid")
+                result = dict(persisted)
+                result["id"] = node_id
+                return node_id, result
+        finally:
+            driver.close()
+
+    @classmethod
+    def validate_governed_create_intent(
+        cls, value: Any, *, repository
+    ) -> dict[str, Any]:
+        if not isinstance(value, dict) or set(value) != {
+            "dataflow_uid",
+            "node",
+            "tags",
+        }:
+            raise ValueError("dataflow create intent is invalid")
+        node = value.get("node")
+        tags = value.get("tags")
+        if not isinstance(node, dict) or set(node) != cls._GOVERNED_NODE_KEYS:
+            raise ValueError("dataflow create intent node is invalid")
+        if not isinstance(tags, list) or len(tags) > 100:
+            raise ValueError("dataflow create intent tags are invalid")
+        flow_uid = ensure_governance_uid(
+            {"uid": str(value.get("dataflow_uid"))}
+        )
+        if node.get("uid") != flow_uid:
+            raise ValueError("dataflow create intent UID mismatch")
+        if node.get("script_type") != "governed" or node.get(
+            "script_path"
+        ) not in {"", None}:
+            raise ValueError("dataflow create intent execution mode is invalid")
+        requirement = cls.validate_governed_requirement(
+            node.get("script_requirement"), repository=repository
+        )
+        if requirement["dataflow_spec"]["dataflow_uid"] != flow_uid:
+            raise ValueError("dataflow create intent requirement UID mismatch")
+        normalized_node = copy.deepcopy(node)
+        normalized_node["script_requirement"] = json.dumps(
+            requirement,
+            ensure_ascii=False,
+            sort_keys=True,
+            separators=(",", ":"),
+        )
+        for key in cls._GOVERNED_NODE_KEYS - {
+            "script_path",
+            "script_requirement",
+        }:
+            if not isinstance(normalized_node.get(key), str):
+                raise ValueError("dataflow create intent node field is invalid")
+        return {
+            "dataflow_uid": flow_uid,
+            "node": normalized_node,
+            "tags": copy.deepcopy(tags),
+        }
+
     @staticmethod
     def get_dataflows(
         page: int = 1,
@@ -204,7 +464,9 @@ class DataFlowService:
             raise e
 
     @staticmethod
-    def create_dataflow(data: Dict[str, Any]) -> Dict[str, Any]:
+    def create_dataflow(
+        data: Dict[str, Any], *, repository=None, actor_uid=None
+    ) -> Dict[str, Any]:
         """
         创建新的数据流
 
@@ -223,25 +485,71 @@ class DataFlowService:
 
             dataflow_name = data["name_zh"]
 
-            # 使用LLM翻译名称生成英文名
-            try:
-                result_list = translate_and_parse(dataflow_name)
-                name_en = (
-                    result_list[0]
-                    if result_list
-                    else dataflow_name.lower().replace(" ", "_")
+            script_requirement = data.get("script_requirement")
+            decoded_requirement = DataFlowService._decode_script_requirement(
+                script_requirement
+            )
+            governed = DataFlowService._signals_governed(
+                data, decoded_requirement
+            )
+            name_en = data.get("name_en")
+            if governed and (
+                not isinstance(name_en, str) or not name_en.strip()
+            ):
+                flow_value = (
+                    decoded_requirement.get("dataflow_spec")
+                    if isinstance(decoded_requirement, dict)
+                    else None
                 )
-            except Exception as e:
-                logger.warning(f"翻译失败,使用默认英文名: {str(e)}")
-                name_en = dataflow_name.lower().replace(" ", "_")
+                flow_uid = (
+                    flow_value.get("dataflow_uid")
+                    if isinstance(flow_value, dict)
+                    else None
+                )
+                name_en = f"dataflow_{str(flow_uid).replace('-', '')}"
+            elif not isinstance(name_en, str) or not name_en.strip():
+                # 使用LLM翻译名称生成英文名
+                try:
+                    result_list = translate_and_parse(dataflow_name)
+                    name_en = (
+                        result_list[0]
+                        if result_list
+                        else dataflow_name.lower().replace(" ", "_")
+                    )
+                except Exception as e:
+                    logger.warning(f"翻译失败,使用默认英文名: {str(e)}")
+                    name_en = dataflow_name.lower().replace(" ", "_")
+            name_en = name_en.strip()
+
+            saga_claim = None
+            receipt = None
+            if governed:
+                DataFlowService._reject_governed_execution_fields(data)
+                if repository is None:
+                    from app.core.data_rules.repository import DataRuleRepository
+
+                    repository = DataRuleRepository(db.session)
+                decoded_requirement = (
+                    DataFlowService.validate_governed_requirement(
+                        decoded_requirement, repository=repository
+                    )
+                )
+                if actor_uid is None:
+                    raise ValueError(
+                        "governed DataFlow requires an authenticated actor"
+                    )
+                receipt = data.get("draft_reservation")
+                script_requirement = decoded_requirement
 
             # 处理 script_requirement,将其转换为 JSON 字符串
-            script_requirement = data.get("script_requirement")
             if script_requirement is not None:
                 # 如果是字典或列表,转换为 JSON 字符串
                 if isinstance(script_requirement, (dict, list)):
                     script_requirement_str = json.dumps(
-                        script_requirement, ensure_ascii=False
+                        script_requirement,
+                        ensure_ascii=False,
+                        sort_keys=governed,
+                        separators=(",", ":") if governed else None,
                     )
                 else:
                     # 如果已经是字符串,直接使用
@@ -263,78 +571,167 @@ class DataFlowService:
                 "script_type": data.get("script_type", "python"),
                 "script_requirement": script_requirement_str,
                 "script_path": "",  # 脚本路径,任务完成后更新
-                "created_at": get_formatted_time(),
-                "updated_at": get_formatted_time(),
             }
+            if not governed:
+                node_data["created_at"] = get_formatted_time()
+                node_data["updated_at"] = get_formatted_time()
+            if governed:
+                node_data["uid"] = decoded_requirement["dataflow_spec"][
+                    "dataflow_uid"
+                ]
+                node_data["script_type"] = "governed"
             ensure_governance_uid(node_data)
 
-            # 创建或获取数据流节点
-            dataflow_id = get_node("DataFlow", name=dataflow_name)
-            if dataflow_id:
-                raise ValueError(f"数据流 '{dataflow_name}' 已存在")
-
-            dataflow_id = create_or_get_node("DataFlow", **node_data)
+            # Governed nodes use the reservation UID as the only create key.
+            if governed:
+                tag_list = copy.deepcopy(data.get("tag", []))
+                if not isinstance(tag_list, list):
+                    tag_list = [tag_list] if tag_list else []
+                create_intent = {
+                    "dataflow_uid": node_data["uid"],
+                    "node": copy.deepcopy(node_data),
+                    "tags": tag_list,
+                }
+                request_payload = copy.deepcopy(data)
+                request_payload.pop("draft_reservation", None)
+                request_payload["script_requirement"] = decoded_requirement
+                create_request = {
+                    "payload": request_payload,
+                    "intent": create_intent,
+                }
+                saga_claim = repository.begin_dataflow_create(
+                    receipt,
+                    actor_uid=actor_uid,
+                    create_request=create_request,
+                    create_intent=create_intent,
+                )
+                if saga_claim["status"] == "completed":
+                    return copy.deepcopy(saga_claim["result"])
+                reserved_uid = saga_claim["dataflow_uid"]
+                if reserved_uid != node_data["uid"]:
+                    raise ValueError(
+                        "draft reservation does not match dataflow_uid"
+                    )
+                stored_intent = DataFlowService.validate_governed_create_intent(
+                    saga_claim["create_intent"], repository=repository
+                )
+                node_data = stored_intent["node"]
+                tag_list = stored_intent["tags"]
+                repository.commit_dataflow_create_claim()
+                try:
+                    dataflow_id, result = (
+                        DataFlowService._merge_governed_dataflow(node_data)
+                    )
+                except Exception as graph_error:
+                    try:
+                        repository.commit_dataflow_create_failure(
+                            reservation_id=receipt["reservation_id"],
+                            lease_token=saga_claim["lease_token"],
+                            error_code="neo4j_create_failed",
+                        )
+                    except Exception:
+                        logger.exception(
+                            "failed to persist governed DataFlow saga failure"
+                        )
+                    raise graph_error
+            else:
+                dataflow_id = get_node("DataFlow", name=dataflow_name)
+                if dataflow_id:
+                    raise ValueError(f"数据流 '{dataflow_name}' 已存在")
+                dataflow_id = create_or_get_node("DataFlow", **node_data)
 
             # 处理标签关系(支持多标签数组)
-            tag_list = data.get("tag", [])
+            tag_list = tag_list if governed else data.get("tag", [])
             if tag_list:
                 try:
-                    DataFlowService._handle_tag_relationships(dataflow_id, tag_list)
+                    DataFlowService._handle_tag_relationships(
+                        dataflow_id, tag_list, strict=governed
+                    )
                 except Exception as e:
+                    if governed:
+                        try:
+                            repository.commit_dataflow_create_failure(
+                                reservation_id=receipt["reservation_id"],
+                                lease_token=saga_claim["lease_token"],
+                                error_code="neo4j_tag_merge_failed",
+                            )
+                        except Exception:
+                            logger.exception(
+                                "failed to persist governed DataFlow tag failure"
+                            )
+                        raise
                     logger.warning(f"处理标签关系时出错: {str(e)}")
 
-            # 成功创建图数据库节点后,写入PG数据库
-            try:
-                DataFlowService._save_to_pg_database(data, dataflow_name, name_en)
-                logger.info(f"数据流信息已写入PG数据库: {dataflow_name}")
-
-                # PG数据库记录成功写入后,在neo4j图数据库中创建script关系
+            # Governed definitions are control-plane assets. Data Factory owns
+            # all later task, code, workflow and product deployment side effects.
+            if not governed:
                 try:
-                    DataFlowService._handle_script_relationships(
+                    DataFlowService._save_to_pg_database(
                         data, dataflow_name, name_en
                     )
-                    logger.info(f"脚本关系创建成功: {dataflow_name}")
-                except Exception as script_error:
-                    logger.warning(f"创建脚本关系失败: {str(script_error)}")
-
-            except Exception as pg_error:
-                logger.error(f"写入PG数据库失败: {str(pg_error)}")
-                # 注意:这里可以选择回滚图数据库操作,但目前保持图数据库数据
-                # 在实际应用中,可能需要考虑分布式事务
-
-            # 返回创建的数据流信息
-            # 查询创建的节点获取完整信息
-            query = "MATCH (n:DataFlow {name_zh: $name_zh}) RETURN n, id(n) as node_id"
-            with connect_graph().session() as session:
-                id_result = session.run(query, name_zh=dataflow_name).single()
-                if id_result:
-                    dataflow_node = id_result["n"]
-                    node_id = id_result["node_id"]
-
-                    # 将节点属性转换为字典
-                    result = dict(dataflow_node)
-                    result["id"] = node_id
-                else:
-                    # 如果查询失败,返回基本信息
-                    result = {
-                        "id": (dataflow_id if isinstance(dataflow_id, int) else None),
-                        "name_zh": dataflow_name,
-                        "name_en": name_en,
-                        "created_at": get_formatted_time(),
-                    }
+                    logger.info(
+                        f"数据流信息已写入PG数据库: {dataflow_name}"
+                    )
 
-            # 注册数据产品到数据服务
-            try:
-                DataFlowService._register_data_product(
-                    data=data,
-                    dataflow_name=dataflow_name,
-                    name_en=name_en,
-                    dataflow_id=result.get("id"),
+                    try:
+                        DataFlowService._handle_script_relationships(
+                            data, dataflow_name, name_en
+                        )
+                        logger.info(f"脚本关系创建成功: {dataflow_name}")
+                    except Exception as script_error:
+                        logger.warning(
+                            f"创建脚本关系失败: {str(script_error)}"
+                        )
+
+                except Exception as pg_error:
+                    logger.error(f"写入PG数据库失败: {str(pg_error)}")
+
+            if governed:
+                result = repository.complete_dataflow_create(
+                    reservation_id=receipt["reservation_id"],
+                    dataflow_uid=node_data["uid"],
+                    lease_token=saga_claim["lease_token"],
+                    request_digest=saga_claim["request_digest"],
+                    dataflow_node_id=dataflow_id,
+                    result=result,
                 )
-                logger.info(f"数据产品注册成功: {dataflow_name}")
-            except Exception as product_error:
-                logger.warning(f"注册数据产品失败: {str(product_error)}")
-                # 不影响主流程,仅记录警告
+            else:
+                # 查询创建的节点获取完整信息
+                query = (
+                    "MATCH (n:DataFlow {name_zh: $name_zh}) "
+                    "RETURN n, id(n) as node_id"
+                )
+                with connect_graph().session() as session:
+                    id_result = session.run(query, name_zh=dataflow_name).single()
+                    if id_result:
+                        dataflow_node = id_result["n"]
+                        node_id = id_result["node_id"]
+                        result = dict(dataflow_node)
+                        result["id"] = node_id
+                    else:
+                        result = {
+                            "id": (
+                                dataflow_id
+                                if isinstance(dataflow_id, int)
+                                else None
+                            ),
+                            "uid": node_data["uid"],
+                            "name_zh": dataflow_name,
+                            "name_en": name_en,
+                            "created_at": get_formatted_time(),
+                        }
+
+            if not governed:
+                try:
+                    DataFlowService._register_data_product(
+                        data=data,
+                        dataflow_name=dataflow_name,
+                        name_en=name_en,
+                        dataflow_id=result.get("id"),
+                    )
+                    logger.info(f"数据产品注册成功: {dataflow_name}")
+                except Exception as product_error:
+                    logger.warning(f"注册数据产品失败: {str(product_error)}")
 
             logger.info(f"创建数据流成功: {dataflow_name}")
             return result
@@ -941,7 +1338,7 @@ class DataFlowService:
                 logger.warning(f"创建子节点关系失败 {child_id}: {str(e)}")
 
     @staticmethod
-    def _handle_tag_relationships(dataflow_id, tag_list):
+    def _handle_tag_relationships(dataflow_id, tag_list, *, strict=False):
         """
         处理多标签关系
 
@@ -962,33 +1359,37 @@ class DataFlowService:
                     tag_id = int(tag_item)
 
             if tag_id:
-                DataFlowService._handle_single_tag_relationship(dataflow_id, tag_id)
+                DataFlowService._handle_single_tag_relationship(
+                    dataflow_id, tag_id, strict=strict
+                )
+            elif strict:
+                raise ValueError("dataflow create intent tag is invalid")
 
     @staticmethod
-    def _handle_single_tag_relationship(dataflow_id, tag_id):
+    def _handle_single_tag_relationship(dataflow_id, tag_id, *, strict=False):
         """处理单个标签关系"""
+        driver = None
         try:
-            # 查找标签节点
-            query = "MATCH (n:DataLabel) WHERE id(n) = $tag_id RETURN n"
-            with connect_graph().session() as session:
-                result = session.run(query, tag_id=tag_id).data()
-
-                # 创建关系 - 使用ID调用relationship_exists
-                if (
-                    result
-                    and dataflow_id
-                    and not relationship_exists(dataflow_id, "LABEL", tag_id)
-                ):
-                    session.run(
-                        "MATCH (a), (b) WHERE id(a) = $dataflow_id "
-                        "AND id(b) = $tag_id "
-                        "CREATE (a)-[:LABEL]->(b)",
-                        dataflow_id=dataflow_id,
-                        tag_id=tag_id,
-                    )
-                    logger.info(f"创建标签关系: {dataflow_id} -> {tag_id}")
+            driver = connect_graph()
+            with driver.session() as session:
+                merged = session.run(
+                    "MATCH (a:DataFlow), (b:DataLabel) "
+                    "WHERE id(a) = $dataflow_id AND id(b) = $tag_id "
+                    "MERGE (a)-[:LABEL]->(b) "
+                    "RETURN count(*) AS merged",
+                    dataflow_id=dataflow_id,
+                    tag_id=tag_id,
+                ).single()
+                if merged is None or merged["merged"] != 1:
+                    raise ValueError("dataflow tag target does not exist")
+                logger.info(f"合并标签关系: {dataflow_id} -> {tag_id}")
         except Exception as e:
+            if strict:
+                raise
             logger.warning(f"创建标签关系失败 {tag_id}: {str(e)}")
+        finally:
+            if driver is not None:
+                driver.close()
 
     @staticmethod
     def update_dataflow_script_path(
@@ -1132,6 +1533,8 @@ class DataFlowService:
     def update_dataflow(
         dataflow_id: int,
         data: Dict[str, Any],
+        *,
+        repository=None,
     ) -> Optional[Dict[str, Any]]:
         """
         更新数据流
@@ -1144,8 +1547,10 @@ class DataFlowService:
             更新后的数据流信息,如果不存在则返回None
         """
         try:
-            # 提取 tag 数组(不作为节点属性存储)
-            tag_list = data.pop("tag", None)
+            data = copy.deepcopy(data)
+            requirement = DataFlowService._decode_script_requirement(
+                data.get("script_requirement")
+            )
 
             # 查找节点
             query = "MATCH (n:DataFlow) WHERE id(n) = $dataflow_id RETURN n"
@@ -1154,6 +1559,49 @@ class DataFlowService:
 
                 if not result:
                     return None
+                existing = dict(result[0]["n"])
+                existing_requirement = (
+                    DataFlowService._decode_script_requirement(
+                        existing.get("script_requirement")
+                    )
+                )
+                governed = DataFlowService._signals_governed(
+                    data, requirement
+                ) or DataFlowService._signals_governed(
+                    existing, existing_requirement
+                )
+                if governed:
+                    if "draft_reservation" in data:
+                        raise ValueError(
+                            "existing DataFlow cannot consume a new draft"
+                        )
+                    DataFlowService._reject_governed_execution_fields(data)
+                    if repository is None:
+                        from app.core.data_rules.repository import (
+                            DataRuleRepository,
+                        )
+
+                        repository = DataRuleRepository(db.session)
+                    requirement = (
+                        DataFlowService.validate_governed_requirement(
+                            requirement, repository=repository
+                        )
+                    )
+                    data["script_requirement"] = requirement
+                    data["script_type"] = "governed"
+                    data["script_path"] = ""
+                    data["uid"] = requirement["dataflow_spec"][
+                        "dataflow_uid"
+                    ]
+                    existing_uid = existing.get("uid")
+                    requested_uid = data["uid"]
+                    if not existing_uid or str(existing_uid) != requested_uid:
+                        raise ValueError(
+                            "dataflow_uid cannot replace an existing identity"
+                        )
+
+                # 提取 tag 数组(不作为节点属性存储)
+                tag_list = data.pop("tag", None)
 
                 # 更新节点属性
                 update_fields = []

+ 5 - 0
app/core/data_research/__init__.py

@@ -0,0 +1,5 @@
+"""Data-research ingestion, governance, and ontology services."""
+
+from app.core.data_research.ingestion import IngestionService
+
+__all__ = ["IngestionService"]

+ 161 - 0
app/core/data_research/artifacts.py

@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+import hashlib
+import io
+import re
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class ArtifactRecord:
+    uid: str
+    source_uid: str
+    filename: str
+    media_type: str
+    size_bytes: int
+    content_hash: str
+    storage_ref: str
+    parser_version: str
+
+
+class ArtifactService:
+    def __init__(self, repository, storage, file_policy, *, uid_factory):
+        self.repository = repository
+        self.storage = storage
+        self.file_policy = file_policy
+        self.uid_factory = uid_factory
+
+    def store(self, source_uid, filename, media_type, content, parser_version):
+        validated = self.file_policy.validate(filename, media_type, content)
+        digest = hashlib.sha256(content).hexdigest()
+        existing = self.repository.find(source_uid, digest, parser_version)
+        if existing is not None:
+            return existing, False
+        object_key = f"data-research/{source_uid}/{digest}"
+        storage_ref = self.storage.put(object_key, content, validated.media_type)
+        record = ArtifactRecord(
+            uid=self.uid_factory(),
+            source_uid=str(source_uid),
+            filename=validated.filename,
+            media_type=validated.media_type,
+            size_bytes=validated.size_bytes,
+            content_hash=digest,
+            storage_ref=storage_ref,
+            parser_version=str(parser_version),
+        )
+        return self.repository.save(record), True
+
+
+def redact_excerpt(value):
+    value = re.sub(r"(?<!\d)(1\d{2})\d{4}(\d{4})(?!\d)", r"\1****\2", str(value or ""))
+    value = re.sub(
+        r"(?i)\b(token|password|secret|api[_-]?key)\s*[=:]\s*[^\s,;]+",
+        lambda match: f"{match.group(1)}=[redacted]",
+        value,
+    )
+    return value
+
+
+class EvidenceService:
+    def __init__(self, session, storage):
+        self.session = session
+        self.storage = storage
+
+    def preview(self, uid):
+        from app.models.data_research import EvidenceFragment
+
+        model = self.session.get(EvidenceFragment, str(uid))
+        if model is None:
+            raise LookupError("evidence was not found")
+        data = model.to_dict()
+        data["excerpt"] = redact_excerpt(data.get("excerpt"))
+        return data
+
+    def list_for_job(self, job_uid):
+        from app.models.data_research import EvidenceFragment
+
+        records = self.session.query(EvidenceFragment).filter_by(
+            job_uid=str(job_uid)
+        ).order_by(EvidenceFragment.created_at.asc()).all()
+        result = []
+        for model in records:
+            data = model.to_dict()
+            data["excerpt"] = redact_excerpt(data.get("excerpt"))
+            result.append(data)
+        return result
+
+    def download(self, uid):
+        from app.models.data_research import EvidenceFragment, SourceArtifact
+
+        evidence = self.session.get(EvidenceFragment, str(uid))
+        artifact = (
+            self.session.get(SourceArtifact, str(evidence.artifact_uid))
+            if evidence is not None and evidence.artifact_uid
+            else None
+        )
+        if artifact is None:
+            raise LookupError("evidence artifact was not found")
+        return self.storage.get(artifact.storage_ref), artifact.filename, artifact.media_type
+
+
+class SqlAlchemyArtifactRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _record(model):
+        return ArtifactRecord(
+            uid=str(model.uid),
+            source_uid=str(model.source_uid),
+            filename=model.filename,
+            media_type=model.media_type,
+            size_bytes=int(model.size_bytes),
+            content_hash=model.content_hash,
+            storage_ref=model.storage_ref,
+            parser_version=model.parser_version,
+        )
+
+    def find(self, source_uid, content_hash, parser_version):
+        from app.models.data_research import SourceArtifact
+
+        model = self.session.query(SourceArtifact).filter_by(
+            source_uid=str(source_uid),
+            content_hash=content_hash,
+            parser_version=str(parser_version),
+        ).first()
+        return self._record(model) if model is not None else None
+
+    def save(self, record):
+        from app.models.data_research import SourceArtifact
+
+        model = SourceArtifact(**record.__dict__)
+        self.session.add(model)
+        self.session.flush()
+        return self._record(model)
+
+
+class MinioArtifactStorage:
+    def __init__(self, client, bucket):
+        self.client = client
+        self.bucket = bucket
+
+    def put(self, object_key, content, media_type):
+        self.client.put_object(
+            self.bucket,
+            object_key,
+            io.BytesIO(content),
+            len(content),
+            content_type=media_type,
+        )
+        return f"minio://{self.bucket}/{object_key}"
+
+    def get(self, storage_ref):
+        prefix = f"minio://{self.bucket}/"
+        if not str(storage_ref).startswith(prefix):
+            raise ValueError("invalid storage reference")
+        response = self.client.get_object(self.bucket, str(storage_ref)[len(prefix):])
+        try:
+            return response.read()
+        finally:
+            response.close()
+            response.release_conn()

+ 78 - 0
app/core/data_research/candidate_decisions.py

@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Callable
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_research.errors import CandidateDecisionInvalid
+
+
+ALLOWED_ACTIONS = frozenset({"reuse", "create", "map", "ignore"})
+
+
+@dataclass(frozen=True)
+class CandidateDecision:
+    uid: str
+    candidate_uid: str
+    action: str
+    data_element_uid: str | None
+    evidence_uids: tuple[str, ...]
+    actor_uid: str | None
+    reason: str | None = None
+
+
+class CandidateDecisionService:
+    def __init__(
+        self,
+        repository,
+        *,
+        data_elements,
+        uid_factory: Callable[[], str] = new_governance_uid,
+    ):
+        self.repository = repository
+        self.data_elements = data_elements
+        self.uid_factory = uid_factory
+
+    def _validate(self, item):
+        if not isinstance(item, dict):
+            raise CandidateDecisionInvalid("decision must be an object")
+        action = str(item.get("action") or "").strip()
+        if action not in ALLOWED_ACTIONS:
+            raise CandidateDecisionInvalid(f"unsupported action: {action}")
+        candidate_uid = str(item.get("candidate_uid") or "").strip()
+        candidate = self.repository.get(candidate_uid)
+        if candidate is None:
+            raise CandidateDecisionInvalid(f"candidate {candidate_uid} was not found")
+        data_element_uid = str(item.get("data_element_uid") or "").strip() or None
+        if action in {"reuse", "map"} and not data_element_uid:
+            raise CandidateDecisionInvalid("data_element_uid is required")
+        return item, candidate, action, data_element_uid
+
+    def decide(self, items, *, actor_uid):
+        try:
+            validated = [self._validate(item) for item in items]
+            decisions = []
+            for item, candidate, action, data_element_uid in validated:
+                evidence_uids = tuple(candidate.get("evidence_uids") or ())
+                if action == "create":
+                    payload = dict(candidate.get("normalized_data") or {})
+                    payload["evidence_uids"] = list(evidence_uids)
+                    element = self.data_elements.create_draft(payload, actor_uid)
+                    data_element_uid = str(element.uid)
+                decisions.append(
+                    CandidateDecision(
+                        uid=self.uid_factory(),
+                        candidate_uid=str(candidate["uid"]),
+                        action=action,
+                        data_element_uid=data_element_uid,
+                        evidence_uids=evidence_uids,
+                        actor_uid=actor_uid,
+                        reason=str(item.get("reason") or "").strip() or None,
+                    )
+                )
+            self.repository.save_decisions(decisions)
+            self.repository.commit()
+            return decisions
+        except Exception:
+            self.repository.rollback()
+            raise

+ 6 - 0
app/core/data_research/catalog/__init__.py

@@ -0,0 +1,6 @@
+from app.core.data_research.catalog.service import (
+    CatalogCollectionService,
+    diff_snapshots,
+)
+
+__all__ = ["CatalogCollectionService", "diff_snapshots"]

+ 74 - 0
app/core/data_research/catalog/base.py

@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+from collections import OrderedDict
+
+from app.core.data_research.catalog.models import (
+    CatalogAsset,
+    CatalogField,
+    CatalogSnapshot,
+)
+
+
+EMPTY_SCOPE_SENTINEL = "__dataops_empty_scope__"
+
+
+def expanding_text_values(values):
+    result = list(values)
+    return result or [EMPTY_SCOPE_SENTINEL]
+
+
+class BaseCatalogCollector:
+    database_type = ""
+
+    @staticmethod
+    def _asset_type(value: str) -> str:
+        return "view" if "VIEW" in str(value).upper() else "table"
+
+    def _snapshot(self, rows, data_source_uid: str):
+        grouped = OrderedDict()
+        for row in rows:
+            schema = str(row["schema_name"])
+            asset_name = str(row["asset_name"])
+            asset_key = f"{data_source_uid}:{schema}.{asset_name}"
+            entry = grouped.setdefault(
+                asset_key,
+                {
+                    "schema": schema,
+                    "name": asset_name,
+                    "asset_type": self._asset_type(row["asset_type"]),
+                    "comment": row.get("asset_comment"),
+                    "fields": [],
+                },
+            )
+            field_name = str(row["column_name"])
+            entry["fields"].append(
+                CatalogField(
+                    key=f"{asset_key}.{field_name}",
+                    schema=schema,
+                    asset=asset_name,
+                    name=field_name,
+                    ordinal_position=int(row["ordinal_position"]),
+                    data_type=str(row["data_type"]),
+                    nullable=str(row["is_nullable"]).upper() == "YES",
+                    default=row.get("column_default"),
+                    comment=row.get("column_comment"),
+                )
+            )
+        assets = tuple(
+            CatalogAsset(
+                key=key,
+                schema=value["schema"],
+                name=value["name"],
+                asset_type=value["asset_type"],
+                comment=value["comment"],
+                fields=tuple(
+                    sorted(value["fields"], key=lambda item: item.ordinal_position)
+                ),
+            )
+            for key, value in grouped.items()
+        )
+        return CatalogSnapshot(
+            data_source_uid=str(data_source_uid),
+            database_type=self.database_type,
+            assets=assets,
+        )

+ 129 - 0
app/core/data_research/catalog/execution.py

@@ -0,0 +1,129 @@
+from __future__ import annotations
+
+from app.core.data_research.catalog.models import CatalogScope
+from app.core.data_research.errors import (
+    IngestionPayloadInvalid,
+    InvalidJobTransition,
+)
+
+_SCOPE_FIELDS = (
+    "include_schemas",
+    "exclude_schemas",
+    "include_tables",
+    "exclude_tables",
+)
+
+
+def catalog_scope_from_parameters(parameters):
+    parameters = parameters or {}
+    if not isinstance(parameters, dict):
+        raise IngestionPayloadInvalid("parameters must be an object")
+    values = {}
+    for name in _SCOPE_FIELDS:
+        raw = parameters.get(name, [])
+        if not isinstance(raw, (list, tuple)):
+            raise IngestionPayloadInvalid(f"{name} must be a list")
+        if any(not isinstance(item, str) or not item.strip() for item in raw):
+            raise IngestionPayloadInvalid(
+                f"{name} must contain non-empty text values"
+            )
+        values[name] = tuple(item.strip() for item in raw)
+    return CatalogScope(**values)
+
+
+class CatalogIngestionExecutor:
+    def __init__(
+        self,
+        ingestion,
+        collector,
+        snapshots,
+        *,
+        commit=lambda: None,
+        rollback=lambda: None,
+        on_snapshot=lambda _job, _snapshot: None,
+    ):
+        self.ingestion = ingestion
+        self.collector = collector
+        self.snapshots = snapshots
+        self.commit = commit
+        self.rollback = rollback
+        self.on_snapshot = on_snapshot
+
+    @staticmethod
+    def _report(snapshot_record):
+        assets = tuple(snapshot_record.snapshot.get("assets") or ())
+        field_count = sum(
+            len(asset.get("fields") or ())
+            for asset in assets
+        )
+        return {
+            "snapshot_uid": snapshot_record.uid,
+            "content_hash": snapshot_record.content_hash,
+            "asset_count": len(assets),
+            "field_count": field_count,
+            "evidence_count": snapshot_record.evidence_count,
+        }
+
+    def execute(self, job_uid):
+        job = self.ingestion.get_job(job_uid)
+        if job.job_type != "catalog_collect":
+            raise IngestionPayloadInvalid(
+                "only catalog_collect jobs can use catalog execution"
+            )
+        scope = catalog_scope_from_parameters(job.parameters)
+        if job.status == "awaiting_review":
+            return job
+        if job.status == "created":
+            job = self.ingestion.transition(job.uid, "queued")
+        if job.status == "queued":
+            job = self.ingestion.transition(job.uid, "extracting")
+        if job.status not in {"extracting", "normalizing", "matching"}:
+            raise InvalidJobTransition(
+                f"{job.status} -> execute is not allowed"
+            )
+
+        try:
+            snapshot_record = self.snapshots.find(
+                job.uid,
+                job.attempt_count,
+            )
+            if job.status in {"extracting", "normalizing"} and snapshot_record is None:
+                snapshot = self.collector.collect(job.source_uid, scope)
+                if job.status == "extracting":
+                    job = self.ingestion.transition(job.uid, "normalizing")
+                snapshot_record = self.snapshots.persist(
+                    job.uid,
+                    job.attempt_count,
+                    snapshot,
+                )
+                self.on_snapshot(job, snapshot_record)
+                self.commit()
+            if job.status == "normalizing":
+                job = self.ingestion.transition(job.uid, "matching")
+            if job.status == "matching":
+                if snapshot_record is None:
+                    raise RuntimeError(
+                        "catalog snapshot is missing for the current attempt"
+                    )
+                job = self.ingestion.transition(
+                    job.uid,
+                    "awaiting_review",
+                    statistics=self._report(snapshot_record),
+                )
+            return job
+        except Exception as error:
+            self.rollback()
+            current = self.ingestion.get_job(job.uid)
+            if current.status in {
+                "queued",
+                "extracting",
+                "normalizing",
+                "matching",
+            }:
+                self.ingestion.transition(
+                    current.uid,
+                    "failed",
+                    statistics=current.statistics,
+                    error=error,
+                )
+            raise

+ 81 - 0
app/core/data_research/catalog/models.py

@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import asdict, dataclass, field
+from typing import Any
+
+
+@dataclass(frozen=True)
+class CatalogScope:
+    include_schemas: tuple[str, ...] = ()
+    exclude_schemas: tuple[str, ...] = ()
+    include_tables: tuple[str, ...] = ()
+    exclude_tables: tuple[str, ...] = ()
+
+
+@dataclass(frozen=True)
+class CatalogField:
+    key: str
+    schema: str
+    asset: str
+    name: str
+    ordinal_position: int
+    data_type: str
+    nullable: bool
+    default: str | None = None
+    comment: str | None = None
+
+
+@dataclass(frozen=True)
+class CatalogAsset:
+    key: str
+    schema: str
+    name: str
+    asset_type: str
+    fields: tuple[CatalogField, ...]
+    comment: str | None = None
+
+
+@dataclass(frozen=True)
+class CatalogSnapshot:
+    data_source_uid: str
+    database_type: str
+    assets: tuple[CatalogAsset, ...]
+
+    @property
+    def content_hash(self) -> str:
+        payload = json.dumps(
+            asdict(self),
+            ensure_ascii=False,
+            sort_keys=True,
+            separators=(",", ":"),
+        )
+        return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+@dataclass(frozen=True)
+class CatalogFieldChange:
+    before: CatalogField
+    after: CatalogField
+
+
+@dataclass(frozen=True)
+class CatalogDiff:
+    added: tuple[CatalogField, ...] = ()
+    removed: tuple[CatalogField, ...] = ()
+    changed: tuple[CatalogFieldChange, ...] = ()
+    renamed: tuple[CatalogFieldChange, ...] = ()
+
+
+@dataclass(frozen=True)
+class CatalogSnapshotRecord:
+    uid: str
+    job_uid: str
+    source_uid: str
+    attempt: int
+    database_type: str
+    content_hash: str
+    snapshot: dict[str, Any]
+    evidence_count: int
+    created_at: Any = None

+ 49 - 0
app/core/data_research/catalog/mysql.py

@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from sqlalchemy import bindparam, text
+
+from app.core.data_research.catalog.base import (
+    BaseCatalogCollector,
+    expanding_text_values,
+)
+
+
+MYSQL_CATALOG_SQL = """
+SELECT
+    c.TABLE_SCHEMA AS schema_name,
+    c.TABLE_NAME AS asset_name,
+    t.TABLE_TYPE AS asset_type,
+    t.TABLE_COMMENT AS asset_comment,
+    c.COLUMN_NAME AS column_name,
+    c.ORDINAL_POSITION AS ordinal_position,
+    c.COLUMN_TYPE AS data_type,
+    c.IS_NULLABLE AS is_nullable,
+    c.COLUMN_DEFAULT AS column_default,
+    c.COLUMN_COMMENT AS column_comment
+FROM information_schema.COLUMNS c
+JOIN information_schema.TABLES t
+  ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME
+WHERE c.TABLE_SCHEMA = :database_name
+  AND (:include_table_count = 0 OR c.TABLE_NAME IN :include_tables)
+  AND (:exclude_table_count = 0 OR c.TABLE_NAME NOT IN :exclude_tables)
+ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION
+"""
+
+
+class MySqlCatalogCollector(BaseCatalogCollector):
+    database_type = "mysql"
+
+    def collect(self, connection, *, data_source_uid, database_name, scope):
+        statement = text(MYSQL_CATALOG_SQL).bindparams(
+            bindparam("include_tables", expanding=True),
+            bindparam("exclude_tables", expanding=True),
+        )
+        parameters = {
+            "database_name": str(database_name),
+            "include_tables": expanding_text_values(scope.include_tables),
+            "exclude_tables": expanding_text_values(scope.exclude_tables),
+            "include_table_count": len(scope.include_tables),
+            "exclude_table_count": len(scope.exclude_tables),
+        }
+        rows = connection.execute(statement, parameters).mappings().all()
+        return self._snapshot(rows, str(data_source_uid))

+ 59 - 0
app/core/data_research/catalog/postgresql.py

@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from sqlalchemy import bindparam, text
+
+from app.core.data_research.catalog.base import (
+    BaseCatalogCollector,
+    expanding_text_values,
+)
+
+
+POSTGRESQL_CATALOG_SQL = """
+SELECT
+    c.table_schema AS schema_name,
+    c.table_name AS asset_name,
+    t.table_type AS asset_type,
+    obj_description(pc.oid) AS asset_comment,
+    c.column_name,
+    c.ordinal_position,
+    c.data_type,
+    c.is_nullable,
+    c.column_default,
+    col_description(pc.oid, c.ordinal_position) AS column_comment
+FROM information_schema.columns c
+JOIN information_schema.tables t
+  ON t.table_schema = c.table_schema AND t.table_name = c.table_name
+LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = c.table_schema
+LEFT JOIN pg_catalog.pg_class pc
+  ON pc.relnamespace = pn.oid AND pc.relname = c.table_name
+WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
+  AND (:include_schema_count = 0 OR c.table_schema IN :include_schemas)
+  AND (:exclude_schema_count = 0 OR c.table_schema NOT IN :exclude_schemas)
+  AND (:include_table_count = 0 OR c.table_name IN :include_tables)
+  AND (:exclude_table_count = 0 OR c.table_name NOT IN :exclude_tables)
+ORDER BY c.table_schema, c.table_name, c.ordinal_position
+"""
+
+
+class PostgreSqlCatalogCollector(BaseCatalogCollector):
+    database_type = "postgresql"
+
+    def collect(self, connection, *, data_source_uid, scope):
+        statement = text(POSTGRESQL_CATALOG_SQL).bindparams(
+            bindparam("include_schemas", expanding=True),
+            bindparam("exclude_schemas", expanding=True),
+            bindparam("include_tables", expanding=True),
+            bindparam("exclude_tables", expanding=True),
+        )
+        parameters = {
+            "include_schemas": expanding_text_values(scope.include_schemas),
+            "exclude_schemas": expanding_text_values(scope.exclude_schemas),
+            "include_tables": expanding_text_values(scope.include_tables),
+            "exclude_tables": expanding_text_values(scope.exclude_tables),
+            "include_schema_count": len(scope.include_schemas),
+            "exclude_schema_count": len(scope.exclude_schemas),
+            "include_table_count": len(scope.include_tables),
+            "exclude_table_count": len(scope.exclude_tables),
+        }
+        rows = connection.execute(statement, parameters).mappings().all()
+        return self._snapshot(rows, str(data_source_uid))

+ 98 - 0
app/core/data_research/catalog/service.py

@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+from app.core.data_research.catalog.models import (
+    CatalogDiff,
+    CatalogFieldChange,
+)
+
+
+def _fields(snapshot):
+    return {
+        field.key: field
+        for asset in snapshot.assets
+        for field in asset.fields
+    }
+
+
+def diff_snapshots(previous, current):
+    before = _fields(previous)
+    after = _fields(current)
+    changed = []
+    for key in sorted(set(before) & set(after)):
+        left = before[key]
+        right = after[key]
+        if (
+            left.data_type,
+            left.nullable,
+            left.default,
+            left.comment,
+        ) != (
+            right.data_type,
+            right.nullable,
+            right.default,
+            right.comment,
+        ):
+            changed.append(CatalogFieldChange(left, right))
+
+    removed = [before[key] for key in sorted(set(before) - set(after))]
+    added = [after[key] for key in sorted(set(after) - set(before))]
+    renamed = []
+    used_added = set()
+    used_removed = set()
+    for old in removed:
+        for new in added:
+            if new.key in used_added:
+                continue
+            if (
+                old.schema,
+                old.asset,
+                old.ordinal_position,
+                old.data_type,
+            ) == (
+                new.schema,
+                new.asset,
+                new.ordinal_position,
+                new.data_type,
+            ):
+                renamed.append(CatalogFieldChange(old, new))
+                used_removed.add(old.key)
+                used_added.add(new.key)
+                break
+    return CatalogDiff(
+        added=tuple(item for item in added if item.key not in used_added),
+        removed=tuple(item for item in removed if item.key not in used_removed),
+        changed=tuple(changed),
+        renamed=tuple(renamed),
+    )
+
+
+class CatalogCollectionService:
+    def __init__(self, manager, *, definition_resolver, collector_resolver):
+        self.manager = manager
+        self.definition_resolver = definition_resolver
+        self.collector_resolver = collector_resolver
+
+    def collect(self, data_source_uid, scope):
+        definition = self.definition_resolver(str(data_source_uid))
+        database_type = (
+            definition.get("database_type")
+            if isinstance(definition, dict)
+            else definition.database_type
+        )
+        database_name = (
+            definition.get("database")
+            if isinstance(definition, dict)
+            else definition.database
+        )
+        collector = self.collector_resolver(database_type)
+        with self.manager.connect(
+            str(data_source_uid),
+            "metadata_collection",
+        ) as connection:
+            kwargs = {
+                "data_source_uid": str(data_source_uid),
+                "scope": scope,
+            }
+            if database_type == "mysql":
+                kwargs["database_name"] = database_name
+            return collector.collect(connection, **kwargs)

+ 122 - 0
app/core/data_research/data_elements.py

@@ -0,0 +1,122 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from typing import Any, Callable
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_research.errors import (
+    DataElementInvalid,
+    DataElementNotFound,
+    DataElementTransitionInvalid,
+    DataElementVersionConflict,
+)
+
+
+ELEMENT_TRANSITIONS = {
+    "draft": frozenset({"in_review"}),
+    "in_review": frozenset({"draft", "published"}),
+    "published": frozenset({"deprecated"}),
+    "deprecated": frozenset({"published", "retired"}),
+    "retired": frozenset(),
+}
+
+
+@dataclass(frozen=True)
+class DataElementRecord:
+    uid: str
+    code: str
+    status: str
+    current_version: int
+    snapshot: dict[str, Any]
+    created_by: str | None = None
+    updated_by: str | None = None
+
+
+def _required(payload, name):
+    value = str(payload.get(name) or "").strip()
+    if not value:
+        raise DataElementInvalid(f"{name} is required")
+    return value
+
+
+class DataElementService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        outbox_enqueue: Callable[..., Any] = lambda **_event: None,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.outbox_enqueue = outbox_enqueue
+
+    def list(self, *, status=None, business_domain_uid=None):
+        return self.repository.list(
+            status=status, business_domain_uid=business_domain_uid
+        )
+
+    def create_draft(self, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DataElementInvalid("payload must be an object")
+        snapshot = {
+            "code": _required(payload, "code"),
+            "name_zh": _required(payload, "name_zh"),
+            "name_en": _required(payload, "name_en"),
+            "definition": _required(payload, "definition"),
+            "logical_type": _required(payload, "logical_type"),
+            "evidence_uids": list(payload.get("evidence_uids") or []),
+            "business_domain_uids": list(payload.get("business_domain_uids") or []),
+        }
+        for name in (
+            "aliases",
+            "format",
+            "value_domain",
+            "unit",
+            "sensitivity",
+            "quality_rules",
+        ):
+            if name in payload:
+                snapshot[name] = payload[name]
+        if self.repository.get_by_code(snapshot["code"]) is not None:
+            raise DataElementInvalid("code already exists")
+        return self.repository.save(
+            DataElementRecord(
+                uid=self.uid_factory(),
+                code=snapshot["code"],
+                status="draft",
+                current_version=1,
+                snapshot=snapshot,
+                created_by=actor_uid,
+                updated_by=actor_uid,
+            )
+        )
+
+    def transition(self, uid, target_status, *, expected_version, actor_uid):
+        record = self.repository.get(str(uid))
+        if record is None:
+            raise DataElementNotFound(f"data element {uid} was not found")
+        if int(expected_version) != int(record.current_version):
+            raise DataElementVersionConflict(
+                f"expected version {expected_version}, current version {record.current_version}"
+            )
+        if target_status not in ELEMENT_TRANSITIONS.get(record.status, frozenset()):
+            raise DataElementTransitionInvalid(
+                f"{record.status} -> {target_status} is not allowed"
+            )
+        updated = self.repository.save(
+            replace(
+                record,
+                status=target_status,
+                current_version=record.current_version + 1,
+                updated_by=actor_uid,
+            )
+        )
+        if target_status == "published":
+            self.outbox_enqueue(
+                aggregate_type="data_element",
+                aggregate_id=updated.uid,
+                event_type="data_element.version_published",
+                payload={"uid": updated.uid, "version": updated.current_version},
+            )
+        return updated

+ 270 - 0
app/core/data_research/device_asset_repository.py

@@ -0,0 +1,270 @@
+from __future__ import annotations
+
+from sqlalchemy import distinct, func, or_
+
+from app.core.data_research.device_assets import (
+    DeviceAssetMappingRecord,
+    DeviceAssetRecord,
+    DeviceAssetVersionRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceAssetSourceMapping,
+    DeviceAssetVersion,
+    IngestionSource,
+)
+
+
+class SqlAlchemyDeviceAssetRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _asset_record(model):
+        return DeviceAssetRecord(
+            uid=str(model.uid),
+            asset_type=model.asset_type,
+            name=model.name,
+            status=model.status,
+            current_version=int(model.current_version),
+            content_hash=model.content_hash,
+            location=model.location,
+            organization=model.organization,
+            responsible_person=model.responsible_person,
+            attributes=dict(model.attributes or {}),
+            created_by=model.created_by,
+            updated_by=model.updated_by,
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+        )
+
+    @staticmethod
+    def _mapping_record(model):
+        return DeviceAssetMappingRecord(
+            uid=str(model.uid),
+            asset_uid=str(model.asset_uid),
+            source_uid=str(model.source_uid),
+            source_entity=model.source_entity,
+            asset_type=model.asset_type,
+            source_code=model.source_code,
+            source_updated_at=model.source_updated_at,
+            first_seen_at=model.first_seen_at,
+            last_seen_at=model.last_seen_at,
+        )
+
+    @staticmethod
+    def _version_record(model):
+        return DeviceAssetVersionRecord(
+            uid=str(model.uid),
+            asset_uid=str(model.asset_uid),
+            version=int(model.version),
+            content_hash=model.content_hash,
+            snapshot=dict(model.snapshot or {}),
+            source_mapping_uid=str(model.source_mapping_uid),
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    def source_is_active(self, source_uid):
+        return (
+            self.session.query(IngestionSource.uid)
+            .filter_by(uid=str(source_uid), status="active")
+            .first()
+            is not None
+        )
+
+    def find_mapping(
+        self,
+        source_uid,
+        source_entity,
+        asset_type,
+        source_code,
+        *,
+        for_update=False,
+    ):
+        query = self.session.query(DeviceAssetSourceMapping).filter_by(
+            source_uid=str(source_uid),
+            source_entity=str(source_entity),
+            asset_type=str(asset_type),
+            source_code=str(source_code),
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._mapping_record(model) if model is not None else None
+
+    def get(self, asset_uid):
+        model = self.session.get(DeviceAsset, str(asset_uid))
+        return self._asset_record(model) if model is not None else None
+
+    @staticmethod
+    def _asset_model(record):
+        return DeviceAsset(
+            uid=record.uid,
+            asset_type=record.asset_type,
+            name=record.name,
+            status=record.status,
+            current_version=record.current_version,
+            content_hash=record.content_hash,
+            location=record.location,
+            organization=record.organization,
+            responsible_person=record.responsible_person,
+            attributes=record.attributes,
+            created_by=record.created_by,
+            updated_by=record.updated_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+        )
+
+    @staticmethod
+    def _mapping_model(record):
+        return DeviceAssetSourceMapping(
+            uid=record.uid,
+            asset_uid=record.asset_uid,
+            source_uid=record.source_uid,
+            source_entity=record.source_entity,
+            asset_type=record.asset_type,
+            source_code=record.source_code,
+            source_updated_at=record.source_updated_at,
+            first_seen_at=record.first_seen_at,
+            last_seen_at=record.last_seen_at,
+        )
+
+    @staticmethod
+    def _version_model(record):
+        return DeviceAssetVersion(
+            uid=record.uid,
+            asset_uid=record.asset_uid,
+            version=record.version,
+            content_hash=record.content_hash,
+            snapshot=record.snapshot,
+            source_mapping_uid=record.source_mapping_uid,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+
+    def create_asset(self, asset, mapping, version):
+        asset_model = self._asset_model(asset)
+        mapping_model = self._mapping_model(mapping)
+        self.session.add(asset_model)
+        self.session.add(mapping_model)
+        self.session.add(self._version_model(version))
+        self.session.flush()
+        return (
+            self._asset_record(asset_model),
+            self._mapping_record(mapping_model),
+        )
+
+    def update_asset(self, asset, mapping, version):
+        asset_model = self.session.get(DeviceAsset, str(asset.uid))
+        mapping_model = self.session.get(
+            DeviceAssetSourceMapping,
+            str(mapping.uid),
+        )
+        for name in (
+            "name",
+            "status",
+            "current_version",
+            "content_hash",
+            "location",
+            "organization",
+            "responsible_person",
+            "attributes",
+            "updated_by",
+            "updated_at",
+        ):
+            setattr(asset_model, name, getattr(asset, name))
+        mapping_model.source_updated_at = mapping.source_updated_at
+        mapping_model.last_seen_at = mapping.last_seen_at
+        self.session.add(self._version_model(version))
+        self.session.flush()
+        return (
+            self._asset_record(asset_model),
+            self._mapping_record(mapping_model),
+        )
+
+    def touch_mapping(
+        self,
+        mapping,
+        *,
+        source_updated_at,
+        last_seen_at,
+    ):
+        model = self.session.get(
+            DeviceAssetSourceMapping,
+            str(mapping.uid),
+        )
+        model.source_updated_at = source_updated_at
+        model.last_seen_at = last_seen_at
+        self.session.flush()
+        return self._mapping_record(model)
+
+    @staticmethod
+    def _filtered(query, filters):
+        keyword = str(filters.get("keyword") or "").strip()
+        if keyword:
+            pattern = f"%{keyword}%"
+            query = query.filter(
+                or_(
+                    DeviceAsset.name.ilike(pattern),
+                    DeviceAsset.location.ilike(pattern),
+                    DeviceAsset.organization.ilike(pattern),
+                    DeviceAsset.responsible_person.ilike(pattern),
+                    DeviceAssetSourceMapping.source_code.ilike(pattern),
+                )
+            )
+        if filters.get("asset_type"):
+            query = query.filter(
+                DeviceAsset.asset_type == str(filters["asset_type"])
+            )
+        if filters.get("status"):
+            query = query.filter(
+                DeviceAsset.status == str(filters["status"])
+            )
+        if filters.get("source_uid"):
+            query = query.filter(
+                DeviceAssetSourceMapping.source_uid
+                == str(filters["source_uid"])
+            )
+        return query
+
+    def search(self, filters, *, page, page_size):
+        base = self.session.query(DeviceAsset).outerjoin(
+            DeviceAssetSourceMapping,
+            DeviceAssetSourceMapping.asset_uid == DeviceAsset.uid,
+        )
+        base = self._filtered(base, filters)
+        total = (
+            base.with_entities(func.count(distinct(DeviceAsset.uid)))
+            .scalar()
+            or 0
+        )
+        rows = (
+            base.distinct()
+            .order_by(DeviceAsset.updated_at.desc(), DeviceAsset.uid.asc())
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._asset_record(row) for row in rows], int(total)
+
+    def list_mappings(self, asset_uid):
+        return [
+            self._mapping_record(model)
+            for model in self.session.query(DeviceAssetSourceMapping)
+            .filter_by(asset_uid=str(asset_uid))
+            .order_by(
+                DeviceAssetSourceMapping.source_entity.asc(),
+                DeviceAssetSourceMapping.source_code.asc(),
+            )
+            .all()
+        ]
+
+    def list_versions(self, asset_uid):
+        return [
+            self._version_record(model)
+            for model in self.session.query(DeviceAssetVersion)
+            .filter_by(asset_uid=str(asset_uid))
+            .order_by(DeviceAssetVersion.version.desc())
+            .all()
+        ]

+ 440 - 0
app/core/data_research/device_assets.py

@@ -0,0 +1,440 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass, replace
+from datetime import datetime
+from typing import Any, Callable
+from uuid import UUID
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.errors import (
+    DeviceAssetInvalid,
+    DeviceAssetNotFound,
+)
+
+
+ASSET_TYPES = frozenset(
+    {
+        "device",
+        "component",
+        "measurement_point",
+        "alarm",
+        "maintenance_record",
+    }
+)
+ASSET_STATUSES = frozenset({"active", "retired"})
+MAX_IMPORT_RECORDS = 500
+MAX_ATTRIBUTES_BYTES = 64_000
+SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "encrypted_payload",
+        "api_key",
+        "token",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "conn_str",
+    }
+)
+
+
+@dataclass(frozen=True)
+class DeviceAssetRecord:
+    uid: str
+    asset_type: str
+    name: str
+    status: str
+    current_version: int
+    content_hash: str
+    location: str | None
+    organization: str | None
+    responsible_person: str | None
+    attributes: dict[str, Any]
+    created_by: str | None
+    updated_by: str | None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceAssetMappingRecord:
+    uid: str
+    asset_uid: str
+    source_uid: str
+    source_entity: str
+    asset_type: str
+    source_code: str
+    source_updated_at: datetime | None
+    first_seen_at: datetime | None = None
+    last_seen_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceAssetVersionRecord:
+    uid: str
+    asset_uid: str
+    version: int
+    content_hash: str
+    snapshot: dict[str, Any]
+    source_mapping_uid: str
+    actor_uid: str | None
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceAssetDetail:
+    asset: DeviceAssetRecord
+    mappings: tuple[DeviceAssetMappingRecord, ...]
+
+
+@dataclass(frozen=True)
+class DeviceAssetImportItem:
+    action: str
+    asset: DeviceAssetRecord
+    mapping: DeviceAssetMappingRecord
+
+
+@dataclass(frozen=True)
+class DeviceAssetImportResult:
+    items: tuple[DeviceAssetImportItem, ...]
+    created_count: int
+    updated_count: int
+    unchanged_count: int
+
+
+def _required_text(payload: dict[str, Any], name: str, *, maximum=300) -> str:
+    value = str(payload.get(name) or "").strip()
+    if not value:
+        raise DeviceAssetInvalid(f"{name} is required")
+    if len(value) > maximum:
+        raise DeviceAssetInvalid(f"{name} exceeds {maximum} characters")
+    return value
+
+
+def _optional_text(payload: dict[str, Any], name: str, *, maximum=300):
+    value = str(payload.get(name) or "").strip()
+    if not value:
+        return None
+    if len(value) > maximum:
+        raise DeviceAssetInvalid(f"{name} exceeds {maximum} characters")
+    return value
+
+
+def _source_time(value: Any) -> datetime | None:
+    if value in (None, ""):
+        return None
+    try:
+        return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+    except (TypeError, ValueError) as exc:
+        raise DeviceAssetInvalid("source_updated_at must be ISO-8601") from exc
+
+
+def _source_uid(payload: dict[str, Any]) -> str:
+    value = _required_text(payload, "source_uid", maximum=100)
+    try:
+        return str(UUID(value))
+    except (AttributeError, TypeError, ValueError) as exc:
+        raise DeviceAssetInvalid("source_uid must be a UUID") from exc
+
+
+def _search_filters(filters: Any) -> dict[str, str]:
+    if not isinstance(filters, dict):
+        raise DeviceAssetInvalid("filters must be an object")
+    normalized = {}
+    keyword = str(filters.get("keyword") or "").strip()
+    if len(keyword) > 300:
+        raise DeviceAssetInvalid("keyword exceeds 300 characters")
+    if keyword:
+        normalized["keyword"] = keyword
+    asset_type = str(filters.get("asset_type") or "").strip()
+    if asset_type and asset_type not in ASSET_TYPES:
+        raise DeviceAssetInvalid("asset_type is not supported")
+    if asset_type:
+        normalized["asset_type"] = asset_type
+    status = str(filters.get("status") or "").strip()
+    if status and status not in ASSET_STATUSES:
+        raise DeviceAssetInvalid("status is not supported")
+    if status:
+        normalized["status"] = status
+    if filters.get("source_uid"):
+        normalized["source_uid"] = _source_uid(filters)
+    return normalized
+
+
+def _reject_secrets(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            normalized = str(key).strip().lower()
+            if normalized in SECRET_KEYS:
+                raise DeviceAssetInvalid(
+                    "secret attributes are not allowed in device assets"
+                )
+            _reject_secrets(item)
+    elif isinstance(value, (list, tuple)):
+        for item in value:
+            _reject_secrets(item)
+
+
+def _normalize_record(raw: Any) -> tuple[dict[str, Any], datetime | None]:
+    if not isinstance(raw, dict):
+        raise DeviceAssetInvalid("each device asset record must be an object")
+    if "uid" in raw or "asset_uid" in raw:
+        raise DeviceAssetInvalid("uid is assigned by the platform")
+    asset_type = _required_text(raw, "asset_type", maximum=40)
+    if asset_type not in ASSET_TYPES:
+        raise DeviceAssetInvalid("asset_type is not supported")
+    status = str(raw.get("status") or "active").strip()
+    if status not in ASSET_STATUSES:
+        raise DeviceAssetInvalid("status is not supported")
+    attributes = raw.get("attributes") or {}
+    if not isinstance(attributes, dict):
+        raise DeviceAssetInvalid("attributes must be an object")
+    _reject_secrets(attributes)
+    serialized = json.dumps(
+        attributes,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    )
+    if len(serialized.encode("utf-8")) > MAX_ATTRIBUTES_BYTES:
+        raise DeviceAssetInvalid("attributes exceed the catalog boundary")
+    normalized_attributes = json.loads(serialized)
+    snapshot = {
+        "asset_type": asset_type,
+        "source_code": _required_text(raw, "source_code"),
+        "name": _required_text(raw, "name"),
+        "status": status,
+        "location": _optional_text(raw, "location"),
+        "organization": _optional_text(raw, "organization"),
+        "responsible_person": _optional_text(raw, "responsible_person"),
+        "attributes": normalized_attributes,
+    }
+    return snapshot, _source_time(raw.get("source_updated_at"))
+
+
+def _content_hash(snapshot: dict[str, Any]) -> str:
+    canonical = json.dumps(
+        snapshot,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+    )
+    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
+
+
+class DeviceAssetService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def import_records(self, payload, actor_uid) -> DeviceAssetImportResult:
+        if not isinstance(payload, dict):
+            raise DeviceAssetInvalid("payload must be an object")
+        source_uid = _source_uid(payload)
+        source_entity = _required_text(
+            payload,
+            "source_entity",
+            maximum=300,
+        )
+        if not self.repository.source_is_active(source_uid):
+            raise DeviceAssetInvalid("source_uid must reference an active source")
+        raw_records = payload.get("records")
+        if not isinstance(raw_records, list) or not raw_records:
+            raise DeviceAssetInvalid("records must be a non-empty array")
+        if len(raw_records) > MAX_IMPORT_RECORDS:
+            raise DeviceAssetInvalid("records cannot exceed 500 items")
+
+        normalized_records = []
+        identities = set()
+        for raw in raw_records:
+            snapshot, source_updated_at = _normalize_record(raw)
+            identity = (
+                source_uid,
+                source_entity,
+                snapshot["asset_type"],
+                snapshot["source_code"],
+            )
+            if identity in identities:
+                raise DeviceAssetInvalid(
+                    "duplicate source identity in import request"
+                )
+            identities.add(identity)
+            normalized_records.append((snapshot, source_updated_at))
+
+        items = []
+        try:
+            for snapshot, source_updated_at in normalized_records:
+                items.append(
+                    self._import_one(
+                        source_uid,
+                        source_entity,
+                        snapshot,
+                        source_updated_at,
+                        actor_uid,
+                    )
+                )
+            self.commit()
+        except Exception:
+            self.rollback()
+            raise
+        actions = [item.action for item in items]
+        return DeviceAssetImportResult(
+            items=tuple(items),
+            created_count=actions.count("created"),
+            updated_count=actions.count("updated"),
+            unchanged_count=actions.count("unchanged"),
+        )
+
+    def _import_one(
+        self,
+        source_uid,
+        source_entity,
+        snapshot,
+        source_updated_at,
+        actor_uid,
+    ):
+        now = self.now_factory()
+        digest = _content_hash(snapshot)
+        mapping = self.repository.find_mapping(
+            source_uid,
+            source_entity,
+            snapshot["asset_type"],
+            snapshot["source_code"],
+            for_update=True,
+        )
+        if mapping is None:
+            asset = DeviceAssetRecord(
+                uid=self.uid_factory(),
+                asset_type=snapshot["asset_type"],
+                name=snapshot["name"],
+                status=snapshot["status"],
+                current_version=1,
+                content_hash=digest,
+                location=snapshot["location"],
+                organization=snapshot["organization"],
+                responsible_person=snapshot["responsible_person"],
+                attributes=dict(snapshot["attributes"]),
+                created_by=actor_uid,
+                updated_by=actor_uid,
+                created_at=now,
+                updated_at=now,
+            )
+            mapping = DeviceAssetMappingRecord(
+                uid=self.uid_factory(),
+                asset_uid=asset.uid,
+                source_uid=source_uid,
+                source_entity=source_entity,
+                asset_type=asset.asset_type,
+                source_code=snapshot["source_code"],
+                source_updated_at=source_updated_at,
+                first_seen_at=now,
+                last_seen_at=now,
+            )
+            version = DeviceAssetVersionRecord(
+                uid=self.uid_factory(),
+                asset_uid=asset.uid,
+                version=1,
+                content_hash=digest,
+                snapshot=dict(snapshot),
+                source_mapping_uid=mapping.uid,
+                actor_uid=actor_uid,
+                created_at=now,
+            )
+            asset, mapping = self.repository.create_asset(
+                asset,
+                mapping,
+                version,
+            )
+            return DeviceAssetImportItem("created", asset, mapping)
+
+        asset = self.repository.get(mapping.asset_uid)
+        if asset is None:
+            raise DeviceAssetInvalid("source mapping references a missing asset")
+        if asset.content_hash == digest:
+            mapping = self.repository.touch_mapping(
+                mapping,
+                source_updated_at=source_updated_at,
+                last_seen_at=now,
+            )
+            return DeviceAssetImportItem("unchanged", asset, mapping)
+
+        asset = replace(
+            asset,
+            name=snapshot["name"],
+            status=snapshot["status"],
+            current_version=asset.current_version + 1,
+            content_hash=digest,
+            location=snapshot["location"],
+            organization=snapshot["organization"],
+            responsible_person=snapshot["responsible_person"],
+            attributes=dict(snapshot["attributes"]),
+            updated_by=actor_uid,
+            updated_at=now,
+        )
+        mapping = replace(
+            mapping,
+            source_updated_at=source_updated_at,
+            last_seen_at=now,
+        )
+        version = DeviceAssetVersionRecord(
+            uid=self.uid_factory(),
+            asset_uid=asset.uid,
+            version=asset.current_version,
+            content_hash=digest,
+            snapshot=dict(snapshot),
+            source_mapping_uid=mapping.uid,
+            actor_uid=actor_uid,
+            created_at=now,
+        )
+        asset, mapping = self.repository.update_asset(
+            asset,
+            mapping,
+            version,
+        )
+        return DeviceAssetImportItem("updated", asset, mapping)
+
+    def search(self, filters, *, page=1, page_size=20):
+        assets, total = self.repository.search(
+            _search_filters(filters or {}),
+            page=int(page),
+            page_size=int(page_size),
+        )
+        return [
+            DeviceAssetDetail(
+                asset=asset,
+                mappings=tuple(self.repository.list_mappings(asset.uid)),
+            )
+            for asset in assets
+        ], total
+
+    def get(self, asset_uid) -> DeviceAssetDetail:
+        asset = self.repository.get(str(asset_uid))
+        if asset is None:
+            raise DeviceAssetNotFound(
+                f"device asset {asset_uid} was not found"
+            )
+        return DeviceAssetDetail(
+            asset=asset,
+            mappings=tuple(self.repository.list_mappings(asset.uid)),
+        )
+
+    def versions(self, asset_uid):
+        self.get(asset_uid)
+        return self.repository.list_versions(str(asset_uid))

+ 295 - 0
app/core/data_research/device_entity_repository.py

@@ -0,0 +1,295 @@
+"""SQLAlchemy persistence for governed device entity resolution."""
+
+from __future__ import annotations
+
+from sqlalchemy import func
+
+from app.core.data_research.device_asset_repository import (
+    SqlAlchemyDeviceAssetRepository,
+)
+from app.core.data_research.device_entity_resolution import (
+    DeviceEntityCandidateRecord,
+    DeviceEntityMergeRecord,
+    DeviceEntityReviewRecord,
+    DeviceEntityRollbackRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceEntityMatchCandidate,
+    DeviceEntityMatchReview,
+    DeviceEntityMergeEvent,
+    DeviceEntityMergeRollback,
+)
+
+
+class SqlAlchemyDeviceEntityResolutionRepository:
+    def __init__(self, session):
+        self.session = session
+        self.assets = SqlAlchemyDeviceAssetRepository(session)
+
+    @staticmethod
+    def _candidate(model):
+        return DeviceEntityCandidateRecord(
+            uid=str(model.uid),
+            left_asset_uid=str(model.left_asset_uid),
+            right_asset_uid=str(model.right_asset_uid),
+            canonical_asset_uid=(
+                str(model.canonical_asset_uid)
+                if model.canonical_asset_uid
+                else None
+            ),
+            status=model.status,
+            suggestion_source=model.suggestion_source,
+            confidence=float(model.confidence),
+            explanation=tuple(
+                dict(item) for item in (model.explanation or [])
+            ),
+            evidence_uids=tuple(model.evidence_uids or []),
+            model_provider=model.model_provider,
+            model_name=model.model_name,
+            current_version=int(model.current_version),
+            created_by=model.created_by,
+            reviewed_by=model.reviewed_by,
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+        )
+
+    @staticmethod
+    def _review(model):
+        return DeviceEntityReviewRecord(
+            uid=str(model.uid),
+            candidate_uid=str(model.candidate_uid),
+            version=int(model.version),
+            decision=model.decision,
+            reason=model.reason,
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _merge(model):
+        return DeviceEntityMergeRecord(
+            uid=str(model.uid),
+            candidate_uid=str(model.candidate_uid),
+            canonical_asset_uid=str(model.canonical_asset_uid),
+            member_asset_uid=str(model.member_asset_uid),
+            review_uid=str(model.review_uid),
+            snapshot=dict(model.snapshot or {}),
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _rollback(model):
+        return DeviceEntityRollbackRecord(
+            uid=str(model.uid),
+            merge_uid=str(model.merge_uid),
+            candidate_uid=str(model.candidate_uid),
+            reason=model.reason,
+            snapshot=dict(model.snapshot or {}),
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    def get_asset_detail(self, uid):
+        asset = self.assets.get(str(uid))
+        if asset is None:
+            return None
+        from app.core.data_research.device_assets import DeviceAssetDetail
+
+        return DeviceAssetDetail(
+            asset=asset,
+            mappings=tuple(self.assets.list_mappings(asset.uid)),
+        )
+
+    def list_matchable_assets(self, asset_type, *, limit):
+        rows = (
+            self.session.query(DeviceAsset)
+            .filter_by(asset_type=str(asset_type), status="active")
+            .order_by(DeviceAsset.uid.asc())
+            .limit(int(limit))
+            .all()
+        )
+        return [
+            self.get_asset_detail(str(model.uid))
+            for model in rows
+        ]
+
+    def find_open_pair(self, left_uid, right_uid):
+        model = (
+            self.session.query(DeviceEntityMatchCandidate)
+            .filter_by(
+                left_asset_uid=str(left_uid),
+                right_asset_uid=str(right_uid),
+            )
+            .filter(
+                DeviceEntityMatchCandidate.status.in_(("pending", "merged"))
+            )
+            .first()
+        )
+        return self._candidate(model) if model is not None else None
+
+    @staticmethod
+    def _candidate_model(record):
+        return DeviceEntityMatchCandidate(
+            uid=record.uid,
+            left_asset_uid=record.left_asset_uid,
+            right_asset_uid=record.right_asset_uid,
+            canonical_asset_uid=record.canonical_asset_uid,
+            status=record.status,
+            suggestion_source=record.suggestion_source,
+            confidence=record.confidence,
+            explanation=list(record.explanation),
+            evidence_uids=list(record.evidence_uids),
+            model_provider=record.model_provider,
+            model_name=record.model_name,
+            current_version=record.current_version,
+            created_by=record.created_by,
+            reviewed_by=record.reviewed_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+        )
+
+    def create_candidate(self, record):
+        model = self._candidate_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._candidate(model)
+
+    def search_candidates(self, filters, *, page, page_size):
+        query = self.session.query(DeviceEntityMatchCandidate)
+        if filters.get("status"):
+            query = query.filter_by(status=str(filters["status"]))
+        if filters.get("suggestion_source"):
+            query = query.filter_by(
+                suggestion_source=str(filters["suggestion_source"])
+            )
+        total = query.with_entities(func.count()).scalar() or 0
+        rows = (
+            query.order_by(
+                DeviceEntityMatchCandidate.updated_at.desc(),
+                DeviceEntityMatchCandidate.uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._candidate(model) for model in rows], int(total)
+
+    def get_candidate(self, uid, *, for_update=False):
+        query = self.session.query(DeviceEntityMatchCandidate).filter_by(
+            uid=str(uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._candidate(model) if model is not None else None
+
+    def update_candidate(self, record):
+        model = self.session.get(DeviceEntityMatchCandidate, str(record.uid))
+        for name in (
+            "canonical_asset_uid",
+            "status",
+            "current_version",
+            "reviewed_by",
+            "updated_at",
+        ):
+            setattr(model, name, getattr(record, name))
+        self.session.flush()
+        return self._candidate(model)
+
+    def append_review(self, record):
+        model = DeviceEntityMatchReview(
+            uid=record.uid,
+            candidate_uid=record.candidate_uid,
+            version=record.version,
+            decision=record.decision,
+            reason=record.reason,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._review(model)
+
+    def active_merge_for_member(self, asset_uid):
+        model = (
+            self.session.query(DeviceEntityMergeEvent)
+            .outerjoin(
+                DeviceEntityMergeRollback,
+                DeviceEntityMergeRollback.merge_uid
+                == DeviceEntityMergeEvent.uid,
+            )
+            .filter(
+                DeviceEntityMergeEvent.member_asset_uid == str(asset_uid),
+                DeviceEntityMergeRollback.uid.is_(None),
+            )
+            .order_by(DeviceEntityMergeEvent.created_at.desc())
+            .first()
+        )
+        return self._merge(model) if model is not None else None
+
+    def create_merge(self, record):
+        model = DeviceEntityMergeEvent(
+            uid=record.uid,
+            candidate_uid=record.candidate_uid,
+            canonical_asset_uid=record.canonical_asset_uid,
+            member_asset_uid=record.member_asset_uid,
+            review_uid=record.review_uid,
+            snapshot=record.snapshot,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._merge(model)
+
+    def get_merge(self, uid, *, for_update=False):
+        query = self.session.query(DeviceEntityMergeEvent).filter_by(
+            uid=str(uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._merge(model) if model is not None else None
+
+    def create_rollback(self, record):
+        model = DeviceEntityMergeRollback(
+            uid=record.uid,
+            merge_uid=record.merge_uid,
+            candidate_uid=record.candidate_uid,
+            reason=record.reason,
+            snapshot=record.snapshot,
+            actor_uid=record.actor_uid,
+            created_at=record.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._rollback(model)
+
+    def list_reviews(self, candidate_uid):
+        return [
+            self._review(model)
+            for model in self.session.query(DeviceEntityMatchReview)
+            .filter_by(candidate_uid=str(candidate_uid))
+            .order_by(DeviceEntityMatchReview.created_at.desc())
+            .all()
+        ]
+
+    def list_merges(self, candidate_uid):
+        return [
+            self._merge(model)
+            for model in self.session.query(DeviceEntityMergeEvent)
+            .filter_by(candidate_uid=str(candidate_uid))
+            .order_by(DeviceEntityMergeEvent.created_at.desc())
+            .all()
+        ]
+
+    def list_rollbacks(self, merge_uid):
+        return [
+            self._rollback(model)
+            for model in self.session.query(DeviceEntityMergeRollback)
+            .filter_by(merge_uid=str(merge_uid))
+            .order_by(DeviceEntityMergeRollback.created_at.desc())
+            .all()
+        ]

+ 737 - 0
app/core/data_research/device_entity_resolution.py

@@ -0,0 +1,737 @@
+"""Governed, explainable, and reversible cross-source device resolution."""
+
+from __future__ import annotations
+
+import unicodedata
+from collections.abc import Callable
+from dataclasses import dataclass, replace
+from datetime import datetime
+from itertools import combinations
+from typing import Any
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.device_assets import ASSET_TYPES, DeviceAssetDetail
+from app.core.data_research.errors import (
+    DeviceEntityConflict,
+    DeviceEntityForbidden,
+    DeviceEntityInvalid,
+    DeviceEntityNotFound,
+)
+
+MAX_MATCH_ASSETS = 500
+MAX_NEW_CANDIDATES = 2_000
+MAX_PAGE_SIZE = 100
+AUTO_MERGE_THRESHOLD = 0.98
+SIGNAL_WEIGHTS = (
+    ("name", 0.50),
+    ("location", 0.15),
+    ("organization", 0.10),
+    ("responsible_person", 0.05),
+    ("model", 0.15),
+    ("source_code", 0.05),
+)
+SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "phone",
+        "mobile",
+        "email",
+        "contact_phone",
+        "contact_email",
+        "raw_measurements",
+        "measurements",
+    }
+)
+
+
+@dataclass(frozen=True)
+class DevicePairScore:
+    confidence: float
+    explanation: tuple[dict[str, Any], ...]
+
+
+@dataclass(frozen=True)
+class DeviceEntityCandidateRecord:
+    uid: str
+    left_asset_uid: str
+    right_asset_uid: str
+    canonical_asset_uid: str | None
+    status: str
+    suggestion_source: str
+    confidence: float
+    explanation: tuple[dict[str, Any], ...]
+    evidence_uids: tuple[str, ...]
+    model_provider: str | None
+    model_name: str | None
+    current_version: int
+    created_by: str | None
+    reviewed_by: str | None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityReviewRecord:
+    uid: str
+    candidate_uid: str
+    version: int
+    decision: str
+    reason: str
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityMergeRecord:
+    uid: str
+    candidate_uid: str
+    canonical_asset_uid: str
+    member_asset_uid: str
+    review_uid: str
+    snapshot: dict[str, Any]
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityRollbackRecord:
+    uid: str
+    merge_uid: str
+    candidate_uid: str
+    reason: str
+    snapshot: dict[str, Any]
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceEntityGenerationResult:
+    records: tuple[DeviceEntityCandidateRecord, ...]
+    created_count: int
+    existing_count: int
+    evaluated_pair_count: int
+    auto_merged_count: int
+
+
+def _normalized(value: Any) -> str:
+    text = unicodedata.normalize("NFKC", str(value or "")).casefold()
+    return "".join(character for character in text if character.isalnum())
+
+
+def _mapping_value(detail: DeviceAssetDetail, name: str) -> str:
+    if name == "source_code":
+        values = sorted(
+            {
+                _normalized(mapping.source_code)
+                for mapping in detail.mappings
+                if _normalized(mapping.source_code)
+            }
+        )
+        return "|".join(values)
+    return ""
+
+
+def _signal_value(detail: DeviceAssetDetail, name: str) -> str:
+    if name == "model":
+        return _normalized((detail.asset.attributes or {}).get("model"))
+    if name == "source_code":
+        return _mapping_value(detail, name)
+    return _normalized(getattr(detail.asset, name, None))
+
+
+def score_device_pair(
+    left: DeviceAssetDetail,
+    right: DeviceAssetDetail,
+) -> DevicePairScore:
+    explanation = []
+    confidence = 0.0
+    for signal, weight in SIGNAL_WEIGHTS:
+        left_value = _signal_value(left, signal)
+        right_value = _signal_value(right, signal)
+        matched = bool(left_value and right_value and left_value == right_value)
+        if matched:
+            confidence += weight
+        explanation.append(
+            {
+                "signal": signal,
+                "matched": matched,
+                "weight": weight,
+                "left": left_value or None,
+                "right": right_value or None,
+            }
+        )
+    return DevicePairScore(
+        confidence=round(confidence, 6),
+        explanation=tuple(explanation),
+    )
+
+
+def _required_text(
+    payload: dict[str, Any],
+    name: str,
+    *,
+    maximum: int = 1_000,
+) -> str:
+    value = str(payload.get(name) or "").strip()
+    if not value:
+        raise DeviceEntityInvalid(f"{name} is required")
+    if len(value) > maximum:
+        raise DeviceEntityInvalid(f"{name} exceeds {maximum} characters")
+    return value
+
+
+def _bounded_float(payload, name, *, default=None):
+    raw = payload.get(name, default)
+    try:
+        value = float(raw)
+    except (TypeError, ValueError) as exc:
+        raise DeviceEntityInvalid(f"{name} must be a number") from exc
+    if value < 0 or value > 1:
+        raise DeviceEntityInvalid(f"{name} must be between 0 and 1")
+    return value
+
+
+def _positive_int(payload, name, *, default, maximum):
+    raw = payload.get(name, default)
+    try:
+        value = int(raw)
+    except (TypeError, ValueError) as exc:
+        raise DeviceEntityInvalid(f"{name} must be an integer") from exc
+    if value < 1 or value > maximum:
+        raise DeviceEntityInvalid(
+            f"{name} must be between 1 and {maximum}"
+        )
+    return value
+
+
+def _reject_sensitive(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().lower() in SECRET_KEYS:
+                raise DeviceEntityInvalid(
+                    "sensitive fields are not allowed in entity resolution"
+                )
+            _reject_sensitive(item)
+    elif isinstance(value, (list, tuple)):
+        for item in value:
+            _reject_sensitive(item)
+
+
+def _evidence_uids(payload, *, required=True) -> tuple[str, ...]:
+    values = payload.get("evidence_uids")
+    if not isinstance(values, list):
+        raise DeviceEntityInvalid("evidence_uids must be an array")
+    records = tuple(
+        dict.fromkeys(
+            str(value).strip()
+            for value in values
+            if str(value).strip()
+        )
+    )
+    if required and not records:
+        raise DeviceEntityInvalid("evidence_uids are required")
+    if len(records) > 100 or any(len(item) > 300 for item in records):
+        raise DeviceEntityInvalid("evidence_uids exceed the governed boundary")
+    return records
+
+
+def _pair(left_uid: str, right_uid: str) -> tuple[str, str]:
+    left_uid = str(left_uid).strip()
+    right_uid = str(right_uid).strip()
+    if not left_uid or not right_uid or left_uid == right_uid:
+        raise DeviceEntityInvalid("candidate requires two distinct assets")
+    return tuple(sorted((left_uid, right_uid)))
+
+
+def _source_uids(detail: DeviceAssetDetail) -> set[str]:
+    return {str(mapping.source_uid) for mapping in detail.mappings}
+
+
+def _asset_snapshot(detail: DeviceAssetDetail) -> dict[str, Any]:
+    return {
+        "uid": detail.asset.uid,
+        "asset_type": detail.asset.asset_type,
+        "name": detail.asset.name,
+        "location": detail.asset.location,
+        "organization": detail.asset.organization,
+        "responsible_person": detail.asset.responsible_person,
+        "model": (detail.asset.attributes or {}).get("model"),
+        "source_mappings": [
+            {
+                "uid": mapping.uid,
+                "source_uid": mapping.source_uid,
+                "source_entity": mapping.source_entity,
+                "source_code": mapping.source_code,
+            }
+            for mapping in detail.mappings
+        ],
+    }
+
+
+class DeviceEntityResolutionService:
+    def __init__(
+        self,
+        repository,
+        *,
+        review_authorizer: Callable[[str], Any] = lambda _actor_uid: None,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        auto_merge_enabled: bool = False,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.repository = repository
+        self.review_authorizer = review_authorizer
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.auto_merge_enabled = bool(auto_merge_enabled)
+        self.commit = commit
+        self.rollback_transaction = rollback
+
+    @staticmethod
+    def _validate_pair(left, right):
+        if left is None or right is None:
+            raise DeviceEntityNotFound("candidate asset was not found")
+        if left.asset.status != "active" or right.asset.status != "active":
+            raise DeviceEntityInvalid("candidate assets must be active")
+        if left.asset.asset_type != right.asset.asset_type:
+            raise DeviceEntityInvalid("candidate assets must have the same type")
+        if _source_uids(left) & _source_uids(right):
+            raise DeviceEntityInvalid(
+                "candidate assets must come from different source systems"
+            )
+
+    @staticmethod
+    def _rule_evidence(left, right):
+        return tuple(
+            dict.fromkeys(
+                (
+                    left.asset.uid,
+                    *(mapping.uid for mapping in left.mappings),
+                    right.asset.uid,
+                    *(mapping.uid for mapping in right.mappings),
+                )
+            )
+        )
+
+    def generate(self, payload, actor_uid) -> DeviceEntityGenerationResult:
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        asset_type = str(payload.get("asset_type") or "device").strip()
+        if asset_type not in ASSET_TYPES:
+            raise DeviceEntityInvalid("asset_type is not supported")
+        threshold = _bounded_float(payload, "threshold", default=0.7)
+        limit = _positive_int(
+            payload,
+            "limit",
+            default=MAX_MATCH_ASSETS,
+            maximum=MAX_MATCH_ASSETS,
+        )
+        details = self.repository.list_matchable_assets(
+            asset_type,
+            limit=limit,
+        )
+        records = []
+        created_count = 0
+        existing_count = 0
+        evaluated = 0
+        auto_merged_count = 0
+        try:
+            for first, second in combinations(details, 2):
+                if len(records) >= MAX_NEW_CANDIDATES:
+                    break
+                left_uid, right_uid = _pair(
+                    first.asset.uid,
+                    second.asset.uid,
+                )
+                left = first if first.asset.uid == left_uid else second
+                right = second if second.asset.uid == right_uid else first
+                try:
+                    self._validate_pair(left, right)
+                except DeviceEntityInvalid:
+                    continue
+                evaluated += 1
+                score = score_device_pair(left, right)
+                if score.confidence < threshold:
+                    continue
+                existing = self.repository.find_open_pair(
+                    left_uid,
+                    right_uid,
+                )
+                if existing is not None:
+                    records.append(existing)
+                    existing_count += 1
+                    continue
+                now = self.now_factory()
+                candidate = DeviceEntityCandidateRecord(
+                    uid=self.uid_factory(),
+                    left_asset_uid=left_uid,
+                    right_asset_uid=right_uid,
+                    canonical_asset_uid=None,
+                    status="pending",
+                    suggestion_source="rule",
+                    confidence=score.confidence,
+                    explanation=score.explanation,
+                    evidence_uids=self._rule_evidence(left, right),
+                    model_provider=None,
+                    model_name=None,
+                    current_version=1,
+                    created_by=actor_uid,
+                    reviewed_by=None,
+                    created_at=now,
+                    updated_at=now,
+                )
+                candidate = self.repository.create_candidate(candidate)
+                created_count += 1
+                if (
+                    self.auto_merge_enabled
+                    and candidate.confidence >= AUTO_MERGE_THRESHOLD
+                ):
+                    self.review_authorizer(actor_uid)
+                    candidate, _review, _merge = self._decide(
+                        candidate,
+                        decision="auto_approve",
+                        canonical_asset_uid=left_uid,
+                        reason="strict deterministic auto-merge threshold met",
+                        actor_uid=actor_uid,
+                    )
+                    auto_merged_count += 1
+                records.append(candidate)
+            self.commit()
+        except Exception:
+            self.rollback_transaction()
+            raise
+        return DeviceEntityGenerationResult(
+            records=tuple(records),
+            created_count=created_count,
+            existing_count=existing_count,
+            evaluated_pair_count=evaluated,
+            auto_merged_count=auto_merged_count,
+        )
+
+    def submit_ai_candidate(self, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        _reject_sensitive(payload)
+        left_uid, right_uid = _pair(
+            payload.get("left_asset_uid"),
+            payload.get("right_asset_uid"),
+        )
+        left = self.repository.get_asset_detail(left_uid)
+        right = self.repository.get_asset_detail(right_uid)
+        self._validate_pair(left, right)
+        if self.repository.active_merge_for_member(left_uid) or (
+            self.repository.active_merge_for_member(right_uid)
+        ):
+            raise DeviceEntityConflict(
+                "candidate asset already belongs to an active merge"
+            )
+        if self.repository.find_open_pair(left_uid, right_uid):
+            raise DeviceEntityConflict("candidate pair already has an active merge")
+        confidence = _bounded_float(payload, "confidence")
+        provider = _required_text(payload, "model_provider", maximum=200)
+        model_name = _required_text(payload, "model_name", maximum=200)
+        explanation_text = _required_text(
+            payload,
+            "explanation",
+            maximum=2_000,
+        )
+        evidence = _evidence_uids(payload)
+        now = self.now_factory()
+        candidate = DeviceEntityCandidateRecord(
+            uid=self.uid_factory(),
+            left_asset_uid=left_uid,
+            right_asset_uid=right_uid,
+            canonical_asset_uid=None,
+            status="pending",
+            suggestion_source="ai",
+            confidence=confidence,
+            explanation=(
+                {
+                    "signal": "ai_explanation",
+                    "matched": True,
+                    "weight": confidence,
+                    "left": explanation_text,
+                    "right": None,
+                },
+            ),
+            evidence_uids=evidence,
+            model_provider=provider,
+            model_name=model_name,
+            current_version=1,
+            created_by=actor_uid,
+            reviewed_by=None,
+            created_at=now,
+            updated_at=now,
+        )
+        try:
+            candidate = self.repository.create_candidate(candidate)
+            self.commit()
+            return candidate
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    @staticmethod
+    def _version(value):
+        try:
+            version = int(value)
+        except (TypeError, ValueError) as exc:
+            raise DeviceEntityInvalid("expected_version must be an integer") from exc
+        if version < 1:
+            raise DeviceEntityInvalid("expected_version must be positive")
+        return version
+
+    def _candidate(self, uid, *, for_update=False):
+        candidate = self.repository.get_candidate(
+            str(uid),
+            for_update=for_update,
+        )
+        if candidate is None:
+            raise DeviceEntityNotFound(f"candidate {uid} was not found")
+        return candidate
+
+    def _decide(
+        self,
+        candidate,
+        *,
+        decision,
+        canonical_asset_uid,
+        reason,
+        actor_uid,
+    ):
+        if decision not in {"approve", "reject", "auto_approve"}:
+            raise DeviceEntityInvalid("decision is not supported")
+        now = self.now_factory()
+        review = DeviceEntityReviewRecord(
+            uid=self.uid_factory(),
+            candidate_uid=candidate.uid,
+            version=candidate.current_version,
+            decision=decision,
+            reason=reason,
+            actor_uid=actor_uid,
+            created_at=now,
+        )
+        review = self.repository.append_review(review)
+        if decision == "reject":
+            candidate = replace(
+                candidate,
+                status="rejected",
+                current_version=candidate.current_version + 1,
+                reviewed_by=actor_uid,
+                updated_at=now,
+            )
+            return self.repository.update_candidate(candidate), review, None
+
+        pair = {candidate.left_asset_uid, candidate.right_asset_uid}
+        if canonical_asset_uid not in pair:
+            raise DeviceEntityInvalid(
+                "canonical_asset_uid must belong to the candidate pair"
+            )
+        member_uid = next(uid for uid in pair if uid != canonical_asset_uid)
+        if self.repository.active_merge_for_member(canonical_asset_uid):
+            raise DeviceEntityConflict(
+                "canonical asset already belongs to an active merge"
+            )
+        if self.repository.active_merge_for_member(member_uid):
+            raise DeviceEntityConflict(
+                "member asset already belongs to an active merge"
+            )
+        canonical = self.repository.get_asset_detail(canonical_asset_uid)
+        member = self.repository.get_asset_detail(member_uid)
+        self._validate_pair(canonical, member)
+        merge = DeviceEntityMergeRecord(
+            uid=self.uid_factory(),
+            candidate_uid=candidate.uid,
+            canonical_asset_uid=canonical_asset_uid,
+            member_asset_uid=member_uid,
+            review_uid=review.uid,
+            snapshot={
+                "candidate": {
+                    "uid": candidate.uid,
+                    "confidence": candidate.confidence,
+                    "suggestion_source": candidate.suggestion_source,
+                    "evidence_uids": list(candidate.evidence_uids),
+                },
+                "canonical": _asset_snapshot(canonical),
+                "member": _asset_snapshot(member),
+            },
+            actor_uid=actor_uid,
+            created_at=now,
+        )
+        merge = self.repository.create_merge(merge)
+        candidate = replace(
+            candidate,
+            canonical_asset_uid=canonical_asset_uid,
+            status="merged",
+            current_version=candidate.current_version + 1,
+            reviewed_by=actor_uid,
+            updated_at=now,
+        )
+        return self.repository.update_candidate(candidate), review, merge
+
+    def review(self, candidate_uid, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        decision = str(payload.get("decision") or "").strip()
+        reason = _required_text(payload, "reason", maximum=1_000)
+        expected_version = self._version(payload.get("expected_version"))
+        self.review_authorizer(actor_uid)
+        try:
+            candidate = self._candidate(candidate_uid, for_update=True)
+            if candidate.status != "pending":
+                raise DeviceEntityConflict(
+                    "candidate is not pending review"
+                )
+            if candidate.current_version != expected_version:
+                raise DeviceEntityConflict("candidate version conflict")
+            result = self._decide(
+                candidate,
+                decision=decision,
+                canonical_asset_uid=str(
+                    payload.get("canonical_asset_uid") or ""
+                ).strip(),
+                reason=reason,
+                actor_uid=actor_uid,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    def rollback(self, merge_uid, payload, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceEntityInvalid("payload must be an object")
+        expected_version = self._version(payload.get("expected_version"))
+        reason = _required_text(payload, "reason", maximum=1_000)
+        self.review_authorizer(actor_uid)
+        try:
+            merge = self.repository.get_merge(
+                str(merge_uid),
+                for_update=True,
+            )
+            if merge is None:
+                raise DeviceEntityNotFound(f"merge {merge_uid} was not found")
+            if self.repository.list_rollbacks(merge.uid):
+                raise DeviceEntityConflict("merge was already rolled back")
+            candidate = self._candidate(
+                merge.candidate_uid,
+                for_update=True,
+            )
+            if candidate.status != "merged":
+                raise DeviceEntityConflict("candidate is not actively merged")
+            if candidate.current_version != expected_version:
+                raise DeviceEntityConflict("candidate version conflict")
+            now = self.now_factory()
+            rollback = DeviceEntityRollbackRecord(
+                uid=self.uid_factory(),
+                merge_uid=merge.uid,
+                candidate_uid=candidate.uid,
+                reason=reason,
+                snapshot={
+                    "merge": {
+                        "uid": merge.uid,
+                        "canonical_asset_uid": merge.canonical_asset_uid,
+                        "member_asset_uid": merge.member_asset_uid,
+                        "review_uid": merge.review_uid,
+                        "created_at": (
+                            merge.created_at.isoformat()
+                            if merge.created_at
+                            else None
+                        ),
+                    },
+                    "candidate_version_before": candidate.current_version,
+                },
+                actor_uid=actor_uid,
+                created_at=now,
+            )
+            rollback = self.repository.create_rollback(rollback)
+            candidate = replace(
+                candidate,
+                status="rolled_back",
+                current_version=candidate.current_version + 1,
+                reviewed_by=actor_uid,
+                updated_at=now,
+            )
+            candidate = self.repository.update_candidate(candidate)
+            self.commit()
+            return candidate, rollback
+        except Exception:
+            self.rollback_transaction()
+            raise
+
+    def search(self, filters, *, page=1, page_size=20):
+        if not isinstance(filters, dict):
+            raise DeviceEntityInvalid("filters must be an object")
+        normalized = {}
+        status = str(filters.get("status") or "").strip()
+        if status:
+            if status not in {"pending", "merged", "rejected", "rolled_back"}:
+                raise DeviceEntityInvalid("status is not supported")
+            normalized["status"] = status
+        source = str(filters.get("suggestion_source") or "").strip()
+        if source:
+            if source not in {"rule", "ai", "manual"}:
+                raise DeviceEntityInvalid("suggestion_source is not supported")
+            normalized["suggestion_source"] = source
+        page = _positive_int(
+            {"page": page},
+            "page",
+            default=1,
+            maximum=1_000_000,
+        )
+        page_size = _positive_int(
+            {"page_size": page_size},
+            "page_size",
+            default=20,
+            maximum=MAX_PAGE_SIZE,
+        )
+        return self.repository.search_candidates(
+            normalized,
+            page=page,
+            page_size=page_size,
+        )
+
+    def get(self, candidate_uid):
+        return self._candidate(candidate_uid)
+
+    def reviews(self, candidate_uid):
+        self._candidate(candidate_uid)
+        return self.repository.list_reviews(str(candidate_uid))
+
+    def merges(self, candidate_uid):
+        self._candidate(candidate_uid)
+        return self.repository.list_merges(str(candidate_uid))
+
+    def rollbacks(self, merge_uid):
+        merge = self.repository.get_merge(str(merge_uid))
+        if merge is None:
+            raise DeviceEntityNotFound(f"merge {merge_uid} was not found")
+        return self.repository.list_rollbacks(str(merge_uid))
+
+
+__all__ = [
+    "AUTO_MERGE_THRESHOLD",
+    "DeviceEntityCandidateRecord",
+    "DeviceEntityConflict",
+    "DeviceEntityForbidden",
+    "DeviceEntityGenerationResult",
+    "DeviceEntityInvalid",
+    "DeviceEntityMergeRecord",
+    "DeviceEntityNotFound",
+    "DeviceEntityResolutionService",
+    "DeviceEntityReviewRecord",
+    "DeviceEntityRollbackRecord",
+    "DevicePairScore",
+    "score_device_pair",
+]

+ 766 - 0
app/core/data_research/device_observability.py

@@ -0,0 +1,766 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from collections import defaultdict, deque
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.errors import (
+    DeviceObservabilityConflict,
+    DeviceObservabilityInvalid,
+    DeviceObservabilityNotFound,
+)
+
+EVENT_TYPES = frozenset({"alarm", "fault", "maintenance", "downtime"})
+EVENT_SEVERITIES = frozenset({"info", "warning", "error", "critical"})
+EVENT_STATUSES = frozenset({"observed", "resolved"})
+NODE_KINDS = frozenset({"asset", "event", "quality_issue"})
+RELATION_TYPES = frozenset(
+    {
+        "part_of",
+        "occurred_on",
+        "indicates",
+        "triggered",
+        "addresses",
+        "impacts",
+        "evidences",
+        "related_to",
+    }
+)
+CAUSAL_RELATION_TYPES = frozenset({"indicates", "triggered", "evidences"})
+SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "encrypted_payload",
+        "phone",
+        "mobile",
+        "email",
+        "id_number",
+    }
+)
+
+
+@dataclass(frozen=True)
+class OperationalEventRecord:
+    uid: str
+    source_uid: str
+    source_entity: str
+    source_code: str
+    event_type: str
+    asset_uid: str
+    component_uid: str | None
+    title: str
+    severity: str
+    status: str
+    occurred_at: datetime
+    ended_at: datetime | None
+    evidence_refs: tuple[dict[str, Any], ...]
+    content_hash: str
+    created_by: str
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class EvidenceRelationRecord:
+    uid: str
+    from_kind: str
+    from_uid: str
+    relation_type: str
+    to_kind: str
+    to_uid: str
+    evidence_refs: tuple[dict[str, Any], ...]
+    source: str
+    created_by: str
+    created_at: datetime | None = None
+
+    @property
+    def identity(self) -> str:
+        raw = "|".join(
+            (
+                self.from_kind,
+                self.from_uid,
+                self.relation_type,
+                self.to_kind,
+                self.to_uid,
+            )
+        )
+        return hashlib.sha256(raw.encode("utf-8")).hexdigest()
+
+
+@dataclass(frozen=True)
+class GraphNode:
+    kind: str
+    uid: str
+    node_type: str
+    label: str
+    occurred_at: datetime | None
+    evidence_refs: tuple[dict[str, Any], ...]
+
+    @property
+    def key(self) -> str:
+        return f"{self.kind}:{self.uid}"
+
+
+@dataclass(frozen=True)
+class GraphProjection:
+    anchor_kind: str
+    anchor_uid: str
+    nodes: tuple[GraphNode, ...]
+    relations: tuple[EvidenceRelationRecord, ...]
+    truncated: bool
+
+
+@dataclass(frozen=True)
+class RootCauseCandidate:
+    event_uid: str
+    event_type: str
+    title: str
+    occurred_at: datetime | None
+    support_level: str
+    path_node_uids: tuple[str, ...]
+    path_relation_uids: tuple[str, ...]
+    path_relation_types: tuple[str, ...]
+    evidence_refs: tuple[dict[str, Any], ...]
+
+
+@dataclass(frozen=True)
+class RootCauseAnalysis:
+    analysis_status: str
+    conclusion: str
+    anchor: GraphNode
+    candidates: tuple[RootCauseCandidate, ...]
+    graph: GraphProjection
+    limitations: tuple[str, ...]
+    generated_at: datetime
+
+
+@dataclass(frozen=True)
+class ObservabilityImportResult:
+    events: tuple[OperationalEventRecord, ...]
+    relations: tuple[EvidenceRelationRecord, ...]
+    created_events: int
+    existing_events: int
+    created_relations: int
+    existing_relations: int
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(UUID(str(value)))
+    except (AttributeError, TypeError, ValueError) as exc:
+        raise DeviceObservabilityInvalid(f"{field} must be a UUID") from exc
+
+
+def _text(value: Any, field: str, maximum: int) -> str:
+    normalized = str(value or "").strip()
+    if not normalized:
+        raise DeviceObservabilityInvalid(f"{field} is required")
+    if len(normalized) > maximum:
+        raise DeviceObservabilityInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return normalized
+
+
+def _optional_text(value: Any, field: str, maximum: int) -> str | None:
+    normalized = str(value or "").strip()
+    if not normalized:
+        return None
+    if len(normalized) > maximum:
+        raise DeviceObservabilityInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return normalized
+
+
+def _timestamp(value: Any, field: str, *, required: bool) -> datetime | None:
+    if value in (None, ""):
+        if required:
+            raise DeviceObservabilityInvalid(f"{field} is required")
+        return None
+    if isinstance(value, datetime):
+        parsed = value
+    else:
+        try:
+            parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                f"{field} must be ISO-8601"
+            ) from exc
+    if parsed.tzinfo is None:
+        raise DeviceObservabilityInvalid(f"{field} must include a timezone")
+    return parsed
+
+
+def _reject_secrets(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().casefold() in SECRET_KEYS:
+                raise DeviceObservabilityInvalid(
+                    "evidence contains a secret or direct identifier"
+                )
+            _reject_secrets(item)
+    elif isinstance(value, (list, tuple)):
+        if len(value) > 100:
+            raise DeviceObservabilityInvalid(
+                "evidence arrays cannot exceed 100 items"
+            )
+        for item in value:
+            _reject_secrets(item)
+
+
+def _evidence(value: Any) -> tuple[dict[str, Any], ...]:
+    if value in (None, {}):
+        return ()
+    if not isinstance(value, dict):
+        raise DeviceObservabilityInvalid("evidence must be an object")
+    _reject_secrets(value)
+    serialized = json.dumps(
+        value,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    if len(serialized.encode("utf-8")) > 20_000:
+        raise DeviceObservabilityInvalid("evidence exceeds 20000 bytes")
+    return ({"type": "source_evidence", "data": value},)
+
+
+def _event_hash(payload: dict[str, Any]) -> str:
+    serialized = json.dumps(
+        payload,
+        ensure_ascii=False,
+        sort_keys=True,
+        separators=(",", ":"),
+        default=str,
+    )
+    return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
+
+
+class DeviceObservabilityService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory=new_governance_uid,
+        commit=lambda: None,
+        rollback=lambda: None,
+        clock=now_china,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.commit = commit
+        self.rollback = rollback
+        self.clock = clock
+
+    def import_evidence(
+        self,
+        payload: Any,
+        actor_uid: Any,
+    ) -> ObservabilityImportResult:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid("payload must be an object")
+        source_uid = _uuid(payload.get("source_uid"), "source_uid")
+        actor = _uuid(actor_uid, "actor_uid")
+        if not self.repository.source_exists(source_uid):
+            raise DeviceObservabilityInvalid(
+                "source_uid must reference an active source"
+            )
+        events_payload = payload.get("events", [])
+        relations_payload = payload.get("relations", [])
+        if not isinstance(events_payload, list) or not isinstance(
+            relations_payload, list
+        ):
+            raise DeviceObservabilityInvalid(
+                "events and relations must be arrays"
+            )
+        if not events_payload and not relations_payload:
+            raise DeviceObservabilityInvalid(
+                "at least one event or relation is required"
+            )
+        if len(events_payload) > 200:
+            raise DeviceObservabilityInvalid("events cannot exceed 200 items")
+        if len(relations_payload) > 500:
+            raise DeviceObservabilityInvalid(
+                "relations cannot exceed 500 items"
+            )
+
+        events = []
+        relations = []
+        created_events = existing_events = 0
+        created_relations = existing_relations = 0
+        try:
+            for item in events_payload:
+                normalized = self._normalize_event(
+                    item,
+                    source_uid=source_uid,
+                    actor_uid=actor,
+                )
+                current = self.repository.get_event_by_identity(
+                    source_uid,
+                    normalized.source_entity,
+                    normalized.source_code,
+                )
+                if current is not None:
+                    if current.content_hash != normalized.content_hash:
+                        raise DeviceObservabilityConflict(
+                            "event source identity already exists "
+                            "with different content"
+                        )
+                    events.append(current)
+                    existing_events += 1
+                    continue
+                self.repository.add_event(normalized)
+                events.append(normalized)
+                created_events += 1
+
+            for item in relations_payload:
+                normalized = self._normalize_relation(
+                    item,
+                    source_uid=source_uid,
+                    actor_uid=actor,
+                )
+                current = self.repository.get_relation(normalized)
+                if current is not None:
+                    if (
+                        current.evidence_refs != normalized.evidence_refs
+                        or current.source != normalized.source
+                    ):
+                        raise DeviceObservabilityConflict(
+                            "relation identity already exists "
+                            "with different evidence"
+                        )
+                    relations.append(current)
+                    existing_relations += 1
+                    continue
+                self.repository.add_relation(normalized)
+                relations.append(normalized)
+                created_relations += 1
+            self.commit()
+        except Exception:
+            self.rollback()
+            raise
+        return ObservabilityImportResult(
+            events=tuple(events),
+            relations=tuple(relations),
+            created_events=created_events,
+            existing_events=existing_events,
+            created_relations=created_relations,
+            existing_relations=existing_relations,
+        )
+
+    def _normalize_event(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        actor_uid: str,
+    ) -> OperationalEventRecord:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(
+                "each event must be an object"
+            )
+        event_type = _text(payload.get("event_type"), "event_type", 30)
+        if event_type not in EVENT_TYPES:
+            raise DeviceObservabilityInvalid("event_type is not supported")
+        severity = _text(
+            payload.get("severity", "warning"),
+            "severity",
+            20,
+        )
+        if severity not in EVENT_SEVERITIES:
+            raise DeviceObservabilityInvalid("severity is not supported")
+        status = _text(payload.get("status", "observed"), "status", 20)
+        if status not in EVENT_STATUSES:
+            raise DeviceObservabilityInvalid("status is not supported")
+        occurred_at = _timestamp(
+            payload.get("occurred_at"),
+            "occurred_at",
+            required=True,
+        )
+        ended_at = _timestamp(
+            payload.get("ended_at"),
+            "ended_at",
+            required=False,
+        )
+        if ended_at is not None and ended_at < occurred_at:
+            raise DeviceObservabilityInvalid(
+                "ended_at cannot be before occurred_at"
+            )
+        asset_uid = _uuid(payload.get("asset_uid"), "asset_uid")
+        if not self.repository.node_exists("asset", asset_uid):
+            raise DeviceObservabilityInvalid("asset_uid does not exist")
+        component_uid = None
+        if payload.get("component_uid"):
+            component_uid = _uuid(
+                payload.get("component_uid"),
+                "component_uid",
+            )
+            if not self.repository.node_exists("asset", component_uid):
+                raise DeviceObservabilityInvalid(
+                    "component_uid does not exist"
+                )
+        evidence_refs = _evidence(payload.get("evidence"))
+        source_entity = _text(
+            payload.get("source_entity"),
+            "source_entity",
+            300,
+        )
+        source_code = _text(
+            payload.get("source_code"),
+            "source_code",
+            300,
+        )
+        title = _text(payload.get("title"), "title", 500)
+        hash_payload = {
+            "source_uid": source_uid,
+            "source_entity": source_entity,
+            "source_code": source_code,
+            "event_type": event_type,
+            "asset_uid": asset_uid,
+            "component_uid": component_uid,
+            "title": title,
+            "severity": severity,
+            "status": status,
+            "occurred_at": occurred_at.isoformat(),
+            "ended_at": ended_at.isoformat() if ended_at else None,
+            "evidence_refs": evidence_refs,
+        }
+        return OperationalEventRecord(
+            uid=str(self.uid_factory()),
+            source_uid=source_uid,
+            source_entity=source_entity,
+            source_code=source_code,
+            event_type=event_type,
+            asset_uid=asset_uid,
+            component_uid=component_uid,
+            title=title,
+            severity=severity,
+            status=status,
+            occurred_at=occurred_at,
+            ended_at=ended_at,
+            evidence_refs=evidence_refs,
+            created_by=actor_uid,
+            created_at=self.clock(),
+            content_hash=_event_hash(hash_payload),
+        )
+
+    def _endpoint(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        field: str,
+    ) -> tuple[str, str]:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(f"{field} must be an object")
+        kind = _text(payload.get("kind"), f"{field}.kind", 30)
+        if kind not in NODE_KINDS:
+            raise DeviceObservabilityInvalid(
+                f"{field}.kind is not supported"
+            )
+        if kind == "event" and not payload.get("uid"):
+            source_entity = _text(
+                payload.get("source_entity"),
+                f"{field}.source_entity",
+                300,
+            )
+            source_code = _text(
+                payload.get("source_code"),
+                f"{field}.source_code",
+                300,
+            )
+            uid = self.repository.resolve_event_identity(
+                source_uid,
+                source_entity,
+                source_code,
+            )
+            if not uid:
+                raise DeviceObservabilityInvalid(
+                    f"{field} event identity does not exist"
+                )
+        else:
+            uid = _uuid(payload.get("uid"), f"{field}.uid")
+        if not self.repository.node_exists(kind, uid):
+            raise DeviceObservabilityInvalid(f"{field} node does not exist")
+        return kind, uid
+
+    def _normalize_relation(
+        self,
+        payload: Any,
+        *,
+        source_uid: str,
+        actor_uid: str,
+    ) -> EvidenceRelationRecord:
+        if not isinstance(payload, dict):
+            raise DeviceObservabilityInvalid(
+                "each relation must be an object"
+            )
+        from_kind, from_uid = self._endpoint(
+            payload.get("from"),
+            source_uid=source_uid,
+            field="from",
+        )
+        to_kind, to_uid = self._endpoint(
+            payload.get("to"),
+            source_uid=source_uid,
+            field="to",
+        )
+        if from_kind == to_kind and from_uid == to_uid:
+            raise DeviceObservabilityInvalid(
+                "relation endpoints must be different"
+            )
+        relation_type = _text(
+            payload.get("relation_type"),
+            "relation_type",
+            40,
+        )
+        if relation_type not in RELATION_TYPES:
+            raise DeviceObservabilityInvalid(
+                "relation_type is not supported"
+            )
+        source = _optional_text(payload.get("source"), "source", 30)
+        if source is None:
+            source = "imported"
+        if source not in {"imported", "manual", "derived"}:
+            raise DeviceObservabilityInvalid("source is not supported")
+        return EvidenceRelationRecord(
+            uid=str(self.uid_factory()),
+            from_kind=from_kind,
+            from_uid=from_uid,
+            relation_type=relation_type,
+            to_kind=to_kind,
+            to_uid=to_uid,
+            evidence_refs=_evidence(payload.get("evidence")),
+            source=source,
+            created_by=actor_uid,
+            created_at=self.clock(),
+        )
+
+    def graph(
+        self,
+        anchor_kind: Any,
+        anchor_uid: Any,
+        *,
+        max_hops: Any = 2,
+    ) -> GraphProjection:
+        kind = _text(anchor_kind, "anchor_kind", 30)
+        if kind not in NODE_KINDS:
+            raise DeviceObservabilityInvalid(
+                "anchor_kind is not supported"
+            )
+        uid = _uuid(anchor_uid, "anchor_uid")
+        try:
+            hops = int(max_hops)
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                "max_hops must be an integer"
+            ) from exc
+        if hops < 1 or hops > 3:
+            raise DeviceObservabilityInvalid(
+                "max_hops must be between 1 and 3"
+            )
+        if not self.repository.node_exists(kind, uid):
+            raise DeviceObservabilityNotFound("anchor node does not exist")
+        return self.repository.graph(
+            kind,
+            uid,
+            max_hops=hops,
+            max_nodes=100,
+            max_edges=200,
+        )
+
+    def events(
+        self,
+        filters: Any,
+        *,
+        page: Any = 1,
+        page_size: Any = 20,
+    ):
+        if not isinstance(filters, dict):
+            raise DeviceObservabilityInvalid("filters must be an object")
+        normalized = {}
+        event_type = _optional_text(
+            filters.get("event_type"),
+            "event_type",
+            30,
+        )
+        if event_type:
+            if event_type not in EVENT_TYPES:
+                raise DeviceObservabilityInvalid(
+                    "event_type is not supported"
+                )
+            normalized["event_type"] = event_type
+        severity = _optional_text(
+            filters.get("severity"),
+            "severity",
+            20,
+        )
+        if severity:
+            if severity not in EVENT_SEVERITIES:
+                raise DeviceObservabilityInvalid(
+                    "severity is not supported"
+                )
+            normalized["severity"] = severity
+        status = _optional_text(filters.get("status"), "status", 20)
+        if status:
+            if status not in EVENT_STATUSES:
+                raise DeviceObservabilityInvalid("status is not supported")
+            normalized["status"] = status
+        for field in ("asset_uid", "source_uid"):
+            if filters.get(field):
+                normalized[field] = _uuid(filters[field], field)
+        keyword = _optional_text(filters.get("keyword"), "keyword", 300)
+        if keyword:
+            normalized["keyword"] = keyword
+        try:
+            current_page = int(page)
+            current_page_size = int(page_size)
+        except (TypeError, ValueError) as exc:
+            raise DeviceObservabilityInvalid(
+                "page and page_size must be integers"
+            ) from exc
+        if current_page < 1:
+            raise DeviceObservabilityInvalid("page must be positive")
+        if current_page_size < 1 or current_page_size > 100:
+            raise DeviceObservabilityInvalid(
+                "page_size must be between 1 and 100"
+            )
+        return self.repository.search_events(
+            normalized,
+            page=current_page,
+            page_size=current_page_size,
+        )
+
+    def root_cause(
+        self,
+        anchor_kind: Any,
+        anchor_uid: Any,
+        *,
+        max_hops: Any = 3,
+    ) -> RootCauseAnalysis:
+        graph = self.graph(
+            anchor_kind,
+            anchor_uid,
+            max_hops=max_hops,
+        )
+        nodes = {item.key: item for item in graph.nodes}
+        anchor_key = f"{graph.anchor_kind}:{graph.anchor_uid}"
+        anchor = nodes[anchor_key]
+        incoming = defaultdict(list)
+        for relation in graph.relations:
+            if relation.relation_type in CAUSAL_RELATION_TYPES:
+                incoming[f"{relation.to_kind}:{relation.to_uid}"].append(
+                    relation
+                )
+
+        candidates = []
+        queue = deque([(anchor_key, (), (anchor.uid,))])
+        visited_depth = {anchor_key: 0}
+        while queue:
+            current_key, path, node_path = queue.popleft()
+            current_node = nodes.get(current_key)
+            for relation in incoming.get(current_key, ()):
+                source_key = f"{relation.from_kind}:{relation.from_uid}"
+                source_node = nodes.get(source_key)
+                if source_node is None:
+                    continue
+                if (
+                    current_node is not None
+                    and current_node.occurred_at is not None
+                    and source_node.occurred_at is not None
+                    and source_node.occurred_at > current_node.occurred_at
+                ):
+                    continue
+                next_path = (relation, *path)
+                next_nodes = (source_node.uid, *node_path)
+                depth = len(next_path)
+                if (
+                    source_node.kind == "event"
+                    and source_node.uid != anchor.uid
+                ):
+                    evidence_refs = []
+                    for item in next_path:
+                        evidence_refs.extend(item.evidence_refs)
+                    evidence_refs.extend(source_node.evidence_refs)
+                    candidates.append(
+                        RootCauseCandidate(
+                            event_uid=source_node.uid,
+                            event_type=source_node.node_type,
+                            title=source_node.label,
+                            occurred_at=source_node.occurred_at,
+                            support_level=(
+                                "direct" if depth == 1 else "indirect"
+                            ),
+                            path_node_uids=next_nodes,
+                            path_relation_uids=tuple(
+                                item.uid for item in next_path
+                            ),
+                            path_relation_types=tuple(
+                                item.relation_type for item in next_path
+                            ),
+                            evidence_refs=tuple(evidence_refs[:20]),
+                        )
+                    )
+                if depth < int(max_hops) and (
+                    source_key not in visited_depth
+                    or depth < visited_depth[source_key]
+                ):
+                    visited_depth[source_key] = depth
+                    queue.append((source_key, next_path, next_nodes))
+
+        unique = []
+        seen = set()
+        for candidate in candidates:
+            if candidate.event_uid in seen:
+                continue
+            seen.add(candidate.event_uid)
+            unique.append(candidate)
+        if unique:
+            status = "supported_candidates"
+            conclusion = "发现有证据支持的根因候选,仍需设备专家确认"
+        else:
+            status = "insufficient_evidence"
+            conclusion = "证据不足,无法确认根因"
+        return RootCauseAnalysis(
+            analysis_status=status,
+            conclusion=conclusion,
+            anchor=anchor,
+            candidates=tuple(unique),
+            graph=graph,
+            limitations=(
+                "候选来自持久化关系证据,不等同于已确认根因。",
+                "未接入或未关联的运行事件不会出现在分析结果中。",
+                "结果不触发自动修复、维修计划或来源数据改写。",
+            ),
+            generated_at=self.clock(),
+        )
+
+
+__all__ = [
+    "DeviceObservabilityConflict",
+    "DeviceObservabilityInvalid",
+    "DeviceObservabilityNotFound",
+    "DeviceObservabilityService",
+    "EvidenceRelationRecord",
+    "GraphNode",
+    "GraphProjection",
+    "ObservabilityImportResult",
+    "OperationalEventRecord",
+    "RootCauseAnalysis",
+    "RootCauseCandidate",
+]

+ 359 - 0
app/core/data_research/device_observability_repository.py

@@ -0,0 +1,359 @@
+"""Canonical PostgreSQL persistence for WP09 device evidence graphs."""
+
+from __future__ import annotations
+
+from sqlalchemy import and_, func, or_
+
+from app.core.data_research.device_observability import (
+    EvidenceRelationRecord,
+    GraphNode,
+    GraphProjection,
+    OperationalEventRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceEvidenceRelation,
+    DeviceOperationalEvent,
+    DeviceQualityIssue,
+    IngestionSource,
+)
+
+
+class SqlAlchemyDeviceObservabilityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _event(model):
+        return OperationalEventRecord(
+            uid=str(model.uid),
+            source_uid=str(model.source_uid),
+            source_entity=model.source_entity,
+            source_code=model.source_code,
+            event_type=model.event_type,
+            asset_uid=str(model.asset_uid),
+            component_uid=(
+                str(model.component_uid) if model.component_uid else None
+            ),
+            title=model.title,
+            severity=model.severity,
+            status=model.status,
+            occurred_at=model.occurred_at,
+            ended_at=model.ended_at,
+            evidence_refs=tuple(
+                dict(item) for item in (model.evidence_refs or [])
+            ),
+            content_hash=model.content_hash,
+            created_by=str(model.created_by),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _relation(model):
+        return EvidenceRelationRecord(
+            uid=str(model.uid),
+            from_kind=model.from_kind,
+            from_uid=str(model.from_uid),
+            relation_type=model.relation_type,
+            to_kind=model.to_kind,
+            to_uid=str(model.to_uid),
+            evidence_refs=tuple(
+                dict(item) for item in (model.evidence_refs or [])
+            ),
+            source=model.source,
+            created_by=str(model.created_by),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _event_model(record):
+        return DeviceOperationalEvent(
+            uid=record.uid,
+            source_uid=record.source_uid,
+            source_entity=record.source_entity,
+            source_code=record.source_code,
+            event_type=record.event_type,
+            asset_uid=record.asset_uid,
+            component_uid=record.component_uid,
+            title=record.title,
+            severity=record.severity,
+            status=record.status,
+            occurred_at=record.occurred_at,
+            ended_at=record.ended_at,
+            evidence_refs=[dict(item) for item in record.evidence_refs],
+            content_hash=record.content_hash,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    @staticmethod
+    def _relation_model(record):
+        return DeviceEvidenceRelation(
+            uid=record.uid,
+            from_kind=record.from_kind,
+            from_uid=record.from_uid,
+            relation_type=record.relation_type,
+            to_kind=record.to_kind,
+            to_uid=record.to_uid,
+            evidence_refs=[dict(item) for item in record.evidence_refs],
+            source=record.source,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    def source_exists(self, source_uid):
+        return bool(
+            self.session.query(IngestionSource.uid)
+            .filter_by(uid=source_uid, status="active")
+            .first()
+        )
+
+    def get_event_by_identity(
+        self,
+        source_uid,
+        source_entity,
+        source_code,
+    ):
+        model = (
+            self.session.query(DeviceOperationalEvent)
+            .filter_by(
+                source_uid=source_uid,
+                source_entity=source_entity,
+                source_code=source_code,
+            )
+            .first()
+        )
+        return self._event(model) if model is not None else None
+
+    def resolve_event_identity(
+        self,
+        source_uid,
+        source_entity,
+        source_code,
+    ):
+        row = (
+            self.session.query(DeviceOperationalEvent.uid)
+            .filter_by(
+                source_uid=source_uid,
+                source_entity=source_entity,
+                source_code=source_code,
+            )
+            .first()
+        )
+        return str(row[0]) if row is not None else None
+
+    def add_event(self, record):
+        model = self._event_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._event(model)
+
+    def get_relation(self, record):
+        model = (
+            self.session.query(DeviceEvidenceRelation)
+            .filter_by(
+                from_kind=record.from_kind,
+                from_uid=record.from_uid,
+                relation_type=record.relation_type,
+                to_kind=record.to_kind,
+                to_uid=record.to_uid,
+            )
+            .first()
+        )
+        return self._relation(model) if model is not None else None
+
+    def add_relation(self, record):
+        model = self._relation_model(record)
+        self.session.add(model)
+        self.session.flush()
+        return self._relation(model)
+
+    def node_exists(self, kind, uid):
+        model_by_kind = {
+            "asset": DeviceAsset,
+            "event": DeviceOperationalEvent,
+            "quality_issue": DeviceQualityIssue,
+        }
+        model = model_by_kind.get(kind)
+        if model is None:
+            return False
+        return bool(self.session.query(model.uid).filter_by(uid=uid).first())
+
+    def _node(self, kind, uid):
+        if kind == "asset":
+            model = self.session.get(DeviceAsset, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="asset",
+                uid=str(model.uid),
+                node_type=model.asset_type,
+                label=model.name,
+                occurred_at=None,
+                evidence_refs=(
+                    {
+                        "type": "device_asset",
+                        "uid": str(model.uid),
+                        "version": int(model.current_version),
+                    },
+                ),
+            )
+        if kind == "event":
+            model = self.session.get(DeviceOperationalEvent, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="event",
+                uid=str(model.uid),
+                node_type=model.event_type,
+                label=model.title,
+                occurred_at=model.occurred_at,
+                evidence_refs=tuple(
+                    dict(item) for item in (model.evidence_refs or [])
+                ),
+            )
+        if kind == "quality_issue":
+            model = self.session.get(DeviceQualityIssue, uid)
+            if model is None:
+                return None
+            return GraphNode(
+                kind="quality_issue",
+                uid=str(model.uid),
+                node_type="quality_issue",
+                label=model.issue_code,
+                occurred_at=model.created_at,
+                evidence_refs=(
+                    {
+                        "type": "quality_issue",
+                        "uid": str(model.uid),
+                        "issue_code": model.issue_code,
+                        "rule_code": model.rule_code,
+                    },
+                ),
+            )
+        return None
+
+    @staticmethod
+    def _endpoint_filter(endpoints):
+        clauses = []
+        for kind, uid in endpoints:
+            clauses.extend(
+                (
+                    and_(
+                        DeviceEvidenceRelation.from_kind == kind,
+                        DeviceEvidenceRelation.from_uid == uid,
+                    ),
+                    and_(
+                        DeviceEvidenceRelation.to_kind == kind,
+                        DeviceEvidenceRelation.to_uid == uid,
+                    ),
+                )
+            )
+        return or_(*clauses)
+
+    def graph(
+        self,
+        anchor_kind,
+        anchor_uid,
+        *,
+        max_hops,
+        max_nodes,
+        max_edges,
+    ):
+        anchor = (str(anchor_kind), str(anchor_uid))
+        frontier = {anchor}
+        visited = {anchor}
+        nodes = {anchor: self._node(*anchor)}
+        relations = []
+        relation_uids = set()
+        truncated = False
+        for _ in range(max_hops):
+            if not frontier:
+                break
+            models = (
+                self.session.query(DeviceEvidenceRelation)
+                .filter(self._endpoint_filter(frontier))
+                .order_by(
+                    DeviceEvidenceRelation.created_at.asc(),
+                    DeviceEvidenceRelation.uid.asc(),
+                )
+                .all()
+            )
+            next_frontier = set()
+            for model in models:
+                if str(model.uid) in relation_uids:
+                    continue
+                if len(relations) >= max_edges:
+                    truncated = True
+                    break
+                relation = self._relation(model)
+                relation_uids.add(relation.uid)
+                relations.append(relation)
+                for endpoint in (
+                    (relation.from_kind, relation.from_uid),
+                    (relation.to_kind, relation.to_uid),
+                ):
+                    if endpoint in visited:
+                        continue
+                    if len(nodes) >= max_nodes:
+                        truncated = True
+                        continue
+                    node = self._node(*endpoint)
+                    if node is None:
+                        continue
+                    visited.add(endpoint)
+                    next_frontier.add(endpoint)
+                    nodes[endpoint] = node
+            frontier = next_frontier
+        return GraphProjection(
+            anchor_kind=anchor[0],
+            anchor_uid=anchor[1],
+            nodes=tuple(item for item in nodes.values() if item is not None),
+            relations=tuple(relations),
+            truncated=truncated,
+        )
+
+    def search_events(self, filters, *, page, page_size):
+        query = self.session.query(DeviceOperationalEvent)
+        if filters.get("event_type"):
+            query = query.filter(
+                DeviceOperationalEvent.event_type == filters["event_type"]
+            )
+        if filters.get("severity"):
+            query = query.filter(
+                DeviceOperationalEvent.severity == filters["severity"]
+            )
+        if filters.get("status"):
+            query = query.filter(
+                DeviceOperationalEvent.status == filters["status"]
+            )
+        if filters.get("asset_uid"):
+            query = query.filter(
+                DeviceOperationalEvent.asset_uid == filters["asset_uid"]
+            )
+        if filters.get("source_uid"):
+            query = query.filter(
+                DeviceOperationalEvent.source_uid == filters["source_uid"]
+            )
+        if filters.get("keyword"):
+            pattern = f"%{filters['keyword']}%"
+            query = query.filter(
+                or_(
+                    DeviceOperationalEvent.title.ilike(pattern),
+                    DeviceOperationalEvent.source_code.ilike(pattern),
+                )
+            )
+        total = int(
+            query.with_entities(func.count(DeviceOperationalEvent.uid)).scalar()
+            or 0
+        )
+        models = (
+            query.order_by(
+                DeviceOperationalEvent.occurred_at.desc(),
+                DeviceOperationalEvent.uid.asc(),
+            )
+            .offset((page - 1) * page_size)
+            .limit(page_size)
+            .all()
+        )
+        return [self._event(model) for model in models], total

+ 1001 - 0
app/core/data_research/device_quality.py

@@ -0,0 +1,1001 @@
+"""Closed device-ledger and fault-maintenance quality evaluation."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import re
+import unicodedata
+from collections import defaultdict
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Any
+from uuid import UUID
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.errors import (
+    DeviceQualityConflict,
+    DeviceQualityInvalid,
+    DeviceQualityNotFound,
+)
+
+PROFILE_NAME = "设备台账与故障质量策略"
+MAX_ASSETS = 5_000
+MAX_SAMPLES_PER_RULE = 100
+SAMPLE_RETENTION_DAYS = 30
+SEVERITIES = frozenset({"info", "warning", "error", "critical"})
+FORMAT_IDENTIFIERS = frozenset({"upper_alnum_dash"})
+ASSET_TYPES = frozenset(
+    {
+        "device",
+        "component",
+        "measurement_point",
+        "alarm",
+        "maintenance_record",
+    }
+)
+RULE_CODES = (
+    "asset_identity_complete",
+    "asset_context_complete",
+    "source_code_unique_normalized",
+    "component_parent_resolved",
+    "fault_code_mapped",
+    "fault_reason_action_complete",
+    "maintenance_closed_loop",
+)
+RULE_PARAMETER_KEYS = {
+    "asset_identity_complete": frozenset(),
+    "asset_context_complete": frozenset({"asset_types"}),
+    "source_code_unique_normalized": frozenset({"format"}),
+    "component_parent_resolved": frozenset({"parent_field"}),
+    "fault_code_mapped": frozenset({"fault_field"}),
+    "fault_reason_action_complete": frozenset(
+        {"cause_field", "action_field"}
+    ),
+    "maintenance_closed_loop": frozenset(
+        {
+            "device_field",
+            "fault_field",
+            "status_field",
+            "completed_at_field",
+            "action_field",
+            "closed_statuses",
+        }
+    ),
+}
+FIELD_PARAMETER_KEYS = frozenset(
+    {
+        "parent_field",
+        "fault_field",
+        "cause_field",
+        "action_field",
+        "device_field",
+        "status_field",
+        "completed_at_field",
+    }
+)
+SECRET_KEYS = frozenset(
+    {
+        "apikey",
+        "authorization",
+        "connectionstring",
+        "credential",
+        "credentials",
+        "dsn",
+        "password",
+        "secret",
+        "token",
+    }
+)
+SOURCE_CODE_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9._-]{1,63}$")
+FIELD_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$")
+
+
+DEFAULT_DEVICE_QUALITY_RULES = (
+    {
+        "code": "asset_identity_complete",
+        "enabled": True,
+        "severity": "critical",
+        "weight": 15,
+        "parameters": {},
+    },
+    {
+        "code": "asset_context_complete",
+        "enabled": True,
+        "severity": "error",
+        "weight": 15,
+        "parameters": {"asset_types": ["device"]},
+    },
+    {
+        "code": "source_code_unique_normalized",
+        "enabled": True,
+        "severity": "critical",
+        "weight": 10,
+        "parameters": {"format": "upper_alnum_dash"},
+    },
+    {
+        "code": "component_parent_resolved",
+        "enabled": True,
+        "severity": "error",
+        "weight": 10,
+        "parameters": {"parent_field": "parent_source_code"},
+    },
+    {
+        "code": "fault_code_mapped",
+        "enabled": True,
+        "severity": "critical",
+        "weight": 15,
+        "parameters": {"fault_field": "fault_code"},
+    },
+    {
+        "code": "fault_reason_action_complete",
+        "enabled": True,
+        "severity": "error",
+        "weight": 15,
+        "parameters": {
+            "cause_field": "cause_code",
+            "action_field": "action_code",
+        },
+    },
+    {
+        "code": "maintenance_closed_loop",
+        "enabled": True,
+        "severity": "critical",
+        "weight": 20,
+        "parameters": {
+            "device_field": "device_source_code",
+            "fault_field": "fault_source_code",
+            "status_field": "status",
+            "completed_at_field": "completed_at",
+            "action_field": "action_code",
+            "closed_statuses": ["closed", "completed"],
+        },
+    },
+)
+
+
+@dataclass(frozen=True)
+class DeviceQualitySourceMapping:
+    uid: str
+    source_uid: str
+    source_entity: str
+    asset_type: str
+    source_code: str
+
+
+@dataclass(frozen=True)
+class DeviceQualityAssetSnapshot:
+    uid: str
+    asset_type: str
+    name: str
+    status: str
+    current_version: int
+    location: str | None
+    organization: str | None
+    responsible_person: str | None
+    attributes: dict[str, Any]
+    mappings: tuple[DeviceQualitySourceMapping, ...]
+
+
+@dataclass(frozen=True)
+class DeviceQualityPolicyVersionRecord:
+    uid: str
+    profile_uid: str
+    version: int
+    status: str
+    rules: tuple[dict[str, Any], ...]
+    content_hash: str
+    created_by: str
+    created_at: datetime
+    published_by: str | None = None
+    published_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceQualityRunRecord:
+    uid: str
+    policy_version_uid: str
+    policy_hash: str
+    source_uid: str | None
+    status: str
+    total_assets: int
+    total_violations: int
+    score: float
+    created_by: str
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class DeviceQualityRuleResultRecord:
+    uid: str
+    run_uid: str
+    rule_code: str
+    severity: str
+    weight: float
+    status: str
+    evaluated_count: int
+    violation_count: int
+    sampled_count: int
+    pass_rate: float
+    weighted_score: float
+    created_at: datetime
+
+
+@dataclass(frozen=True)
+class DeviceQualityViolationRecord:
+    uid: str
+    run_uid: str
+    rule_code: str
+    severity: str
+    asset_uid: str
+    field_name: str
+    source_uid: str | None
+    source_mapping_uid: str | None
+    message: str
+    evidence: dict[str, Any]
+    created_at: datetime
+    expires_at: datetime
+
+
+@dataclass(frozen=True)
+class DeviceQualityAssetScoreRecord:
+    uid: str
+    run_uid: str
+    asset_uid: str
+    asset_type: str
+    status: str
+    evaluated_rule_count: int
+    violation_count: int
+    score: float
+    created_at: datetime
+
+
+def _canonical(value: Any) -> str:
+    try:
+        return json.dumps(
+            value,
+            sort_keys=True,
+            separators=(",", ":"),
+            ensure_ascii=False,
+        )
+    except (TypeError, ValueError) as exc:
+        raise DeviceQualityInvalid("quality policy must be JSON serializable") from exc
+
+
+def _hash(value: Any) -> str:
+    return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
+
+
+def _normalized_key(value: Any) -> str:
+    return re.sub(r"[^a-z0-9]", "", str(value).casefold())
+
+
+def _reject_secret_material(value: Any, path: str = "$") -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if _normalized_key(key) in SECRET_KEYS:
+                raise DeviceQualityInvalid(
+                    f"unsupported quality rule parameter at {path}.{key}"
+                )
+            _reject_secret_material(item, f"{path}.{key}")
+    elif isinstance(value, list):
+        for index, item in enumerate(value):
+            _reject_secret_material(item, f"{path}[{index}]")
+
+
+def _required_actor(value: Any) -> str:
+    actor = str(value or "").strip()
+    if not actor or len(actor) > 120:
+        raise DeviceQualityInvalid("actor uid is required")
+    return actor
+
+
+def _optional_source_uid(value: Any) -> str | None:
+    if value is None or str(value).strip() == "":
+        return None
+    text = str(value).strip()
+    try:
+        return str(UUID(text))
+    except (TypeError, ValueError, AttributeError) as exc:
+        raise DeviceQualityInvalid("source_uid must be a valid UUID") from exc
+
+
+def _positive_integer(value: Any, label: str) -> int:
+    if isinstance(value, bool):
+        raise DeviceQualityInvalid(f"{label} must be a positive integer")
+    try:
+        result = int(value)
+    except (TypeError, ValueError) as exc:
+        raise DeviceQualityInvalid(f"{label} must be a positive integer") from exc
+    if result < 1:
+        raise DeviceQualityInvalid(f"{label} must be a positive integer")
+    return result
+
+
+def _field_name(value: Any, label: str) -> str:
+    result = str(value or "").strip()
+    if not FIELD_PATTERN.fullmatch(result):
+        raise DeviceQualityInvalid(f"{label} must be a safe field identifier")
+    return result
+
+
+def _validate_parameters(code: str, value: Any) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise DeviceQualityInvalid("quality rule parameters must be an object")
+    _reject_secret_material(value, f"$.{code}.parameters")
+    allowed = RULE_PARAMETER_KEYS[code]
+    unknown = sorted(set(value) - allowed)
+    if unknown:
+        raise DeviceQualityInvalid(
+            f"unsupported quality rule parameter: {', '.join(unknown)}"
+        )
+    missing = sorted(allowed - set(value))
+    if missing:
+        raise DeviceQualityInvalid(
+            f"missing quality rule parameter: {', '.join(missing)}"
+        )
+    normalized = copy.deepcopy(value)
+    for key in FIELD_PARAMETER_KEYS & set(normalized):
+        normalized[key] = _field_name(normalized[key], key)
+    if "asset_types" in normalized:
+        asset_types = normalized["asset_types"]
+        if (
+            not isinstance(asset_types, list)
+            or not asset_types
+            or len(asset_types) > len(ASSET_TYPES)
+        ):
+            raise DeviceQualityInvalid("asset_types must be a bounded list")
+        normalized["asset_types"] = sorted({str(item) for item in asset_types})
+        if not set(normalized["asset_types"]) <= ASSET_TYPES:
+            raise DeviceQualityInvalid("asset_types contains an unsupported type")
+    if "format" in normalized and normalized["format"] not in FORMAT_IDENTIFIERS:
+        raise DeviceQualityInvalid("unsupported source code format identifier")
+    if "closed_statuses" in normalized:
+        statuses = normalized["closed_statuses"]
+        if not isinstance(statuses, list) or not statuses or len(statuses) > 10:
+            raise DeviceQualityInvalid("closed_statuses must be a bounded list")
+        normalized["closed_statuses"] = sorted(
+            {
+                str(item).strip().casefold()
+                for item in statuses
+                if str(item).strip()
+            }
+        )
+        if not normalized["closed_statuses"]:
+            raise DeviceQualityInvalid("closed_statuses cannot be empty")
+    return normalized
+
+
+def validate_device_quality_rules(
+    value: Any,
+) -> tuple[dict[str, Any], ...]:
+    if not isinstance(value, (list, tuple)):
+        raise DeviceQualityInvalid("quality rules must be an array")
+    if len(value) != len(RULE_CODES):
+        raise DeviceQualityInvalid("quality policy has an unsupported rule code set")
+    normalized_by_code: dict[str, dict[str, Any]] = {}
+    for raw in value:
+        if not isinstance(raw, dict):
+            raise DeviceQualityInvalid("quality rule must be an object")
+        if set(raw) != {
+            "code",
+            "enabled",
+            "severity",
+            "weight",
+            "parameters",
+        }:
+            raise DeviceQualityInvalid("quality rule contains unsupported fields")
+        code = str(raw.get("code") or "").strip()
+        if code not in RULE_CODES or code in normalized_by_code:
+            raise DeviceQualityInvalid("quality policy has an unsupported rule code")
+        enabled = raw.get("enabled")
+        if not isinstance(enabled, bool):
+            raise DeviceQualityInvalid("quality rule enabled must be boolean")
+        severity = str(raw.get("severity") or "").strip()
+        if severity not in SEVERITIES:
+            raise DeviceQualityInvalid("quality rule severity is unsupported")
+        weight = raw.get("weight")
+        if (
+            isinstance(weight, bool)
+            or not isinstance(weight, (int, float))
+            or weight < 0
+            or weight > 100
+            or (enabled and weight <= 0)
+        ):
+            raise DeviceQualityInvalid("quality rule weight is invalid")
+        normalized_by_code[code] = {
+            "code": code,
+            "enabled": enabled,
+            "severity": severity,
+            "weight": float(weight),
+            "parameters": _validate_parameters(code, raw.get("parameters")),
+        }
+    if set(normalized_by_code) != set(RULE_CODES):
+        raise DeviceQualityInvalid("quality policy has an unsupported rule code set")
+    total = sum(
+        item["weight"]
+        for item in normalized_by_code.values()
+        if item["enabled"]
+    )
+    if abs(total - 100.0) > 0.000001:
+        raise DeviceQualityInvalid("enabled quality rule weights must sum to 100")
+    return tuple(
+        copy.deepcopy(normalized_by_code[code])
+        for code in RULE_CODES
+    )
+
+
+def _normalize_code(value: Any) -> str:
+    normalized = unicodedata.normalize("NFKC", str(value or ""))
+    return "".join(normalized.split()).upper()
+
+
+def _safe_text(value: Any, maximum: int = 300) -> str:
+    text = unicodedata.normalize("NFKC", str(value or "")).strip()
+    return text[:maximum]
+
+
+def _mapping_for(asset: DeviceQualityAssetSnapshot):
+    return asset.mappings[0] if asset.mappings else None
+
+
+def _source_evidence(asset: DeviceQualityAssetSnapshot) -> dict[str, Any]:
+    mapping = _mapping_for(asset)
+    return {
+        "asset_uid": asset.uid,
+        "asset_type": asset.asset_type,
+        "asset_version": int(asset.current_version),
+        "source_uid": mapping.source_uid if mapping else None,
+        "source_mapping_uid": mapping.uid if mapping else None,
+        "source_entity": mapping.source_entity if mapping else None,
+        "source_code": _safe_text(mapping.source_code) if mapping else None,
+    }
+
+
+def _is_iso_datetime(value: Any) -> bool:
+    text = str(value or "").strip()
+    if not text:
+        return False
+    try:
+        datetime.fromisoformat(text.replace("Z", "+00:00"))
+    except ValueError:
+        return False
+    return True
+
+
+def _applicable(
+    rule_code: str,
+    asset: DeviceQualityAssetSnapshot,
+    parameters: dict[str, Any],
+) -> bool:
+    if rule_code == "asset_identity_complete":
+        return True
+    if rule_code == "asset_context_complete":
+        return asset.asset_type in set(parameters["asset_types"])
+    if rule_code == "source_code_unique_normalized":
+        return bool(asset.mappings)
+    if rule_code == "component_parent_resolved":
+        return asset.asset_type == "component"
+    if rule_code in {"fault_code_mapped", "fault_reason_action_complete"}:
+        return asset.asset_type == "alarm"
+    if rule_code == "maintenance_closed_loop":
+        return asset.asset_type == "maintenance_record"
+    return False
+
+
+class DeviceQualityService:
+    def __init__(
+        self,
+        repository,
+        *,
+        publish_authorizer: Callable[[str], None],
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        commit: Callable[[], None] = lambda: None,
+        rollback: Callable[[], None] = lambda: None,
+    ):
+        self.repository = repository
+        self.publish_authorizer = publish_authorizer
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def _create_version(
+        self,
+        *,
+        rules: Any,
+        actor_uid: str,
+        expected_version: int | None,
+    ) -> DeviceQualityPolicyVersionRecord:
+        actor = _required_actor(actor_uid)
+        normalized = validate_device_quality_rules(rules)
+        content_hash = _hash(list(normalized))
+        latest = self.repository.latest_version()
+        if expected_version is not None:
+            expected = _positive_integer(expected_version, "expected_version")
+            actual = latest.version if latest is not None else 0
+            if actual != expected:
+                raise DeviceQualityConflict("quality policy revision is stale")
+        existing = self.repository.find_version_by_hash(content_hash)
+        if existing is not None:
+            return existing
+        profile_uid = self.repository.ensure_profile(
+            name=PROFILE_NAME,
+            actor_uid=actor,
+        )
+        now = self.now_factory()
+        record = DeviceQualityPolicyVersionRecord(
+            uid=self.uid_factory(),
+            profile_uid=profile_uid,
+            version=(latest.version + 1 if latest is not None else 1),
+            status="draft",
+            rules=normalized,
+            content_hash=content_hash,
+            created_by=actor,
+            created_at=now,
+        )
+        try:
+            result = self.repository.create_version(record)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def bootstrap(self, *, actor_uid: str) -> DeviceQualityPolicyVersionRecord:
+        return self._create_version(
+            rules=DEFAULT_DEVICE_QUALITY_RULES,
+            actor_uid=actor_uid,
+            expected_version=None,
+        )
+
+    def revise(
+        self,
+        *,
+        rules: Any,
+        expected_version: int,
+        actor_uid: str,
+    ) -> DeviceQualityPolicyVersionRecord:
+        return self._create_version(
+            rules=rules,
+            actor_uid=actor_uid,
+            expected_version=expected_version,
+        )
+
+    def publish(
+        self,
+        version_uid: str,
+        *,
+        actor_uid: str,
+    ) -> DeviceQualityPolicyVersionRecord:
+        actor = _required_actor(actor_uid)
+        record = self.repository.get_version(version_uid, for_update=True)
+        if record is None:
+            raise DeviceQualityNotFound("quality policy version was not found")
+        if record.status == "published":
+            return record
+        if record.status != "draft":
+            raise DeviceQualityConflict("quality policy version cannot be published")
+        self.publish_authorizer(actor)
+        try:
+            published = self.repository.publish_version(
+                record,
+                actor_uid=actor,
+                published_at=self.now_factory(),
+            )
+            self.commit()
+            return published
+        except Exception:
+            self.rollback()
+            raise
+
+    def profile(self) -> dict[str, Any]:
+        latest = self.repository.latest_version()
+        active = self.repository.active_version()
+        return {
+            "profile_uid": (
+                latest.profile_uid
+                if latest is not None
+                else getattr(self.repository, "profile_uid", None)
+            ),
+            "name": PROFILE_NAME,
+            "latest_version": latest,
+            "active_version": active,
+        }
+
+    def versions(self) -> tuple[DeviceQualityPolicyVersionRecord, ...]:
+        return tuple(self.repository.list_versions())
+
+    @staticmethod
+    def _indexes(assets):
+        mappings = defaultdict(list)
+        for item in assets:
+            for source_mapping in item.mappings:
+                key = (
+                    source_mapping.source_uid,
+                    source_mapping.asset_type,
+                    _normalize_code(source_mapping.source_code),
+                )
+                mappings[key].append(item)
+        return mappings
+
+    @staticmethod
+    def _failure(
+        rule_code: str,
+        asset: DeviceQualityAssetSnapshot,
+        parameters: dict[str, Any],
+        mapping_index,
+        code_sets,
+    ) -> tuple[str, str, dict[str, Any]] | None:
+        attributes = asset.attributes or {}
+        if rule_code == "asset_identity_complete":
+            if not asset.mappings or any(
+                not _safe_text(item.source_code)
+                for item in asset.mappings
+            ):
+                return (
+                    "source_code",
+                    "设备资产缺少可追溯的来源标识",
+                    _source_evidence(asset),
+                )
+            return None
+        if rule_code == "asset_context_complete":
+            missing = [
+                field
+                for field in ("location", "organization", "responsible_person")
+                if not _safe_text(getattr(asset, field))
+            ]
+            if missing:
+                return (
+                    ",".join(missing),
+                    "设备台账位置、组织或责任人不完整",
+                    {**_source_evidence(asset), "missing_fields": missing},
+                )
+            return None
+        if rule_code == "source_code_unique_normalized":
+            bad = []
+            duplicates = []
+            for source_mapping in asset.mappings:
+                raw = _safe_text(source_mapping.source_code)
+                normalized = _normalize_code(raw)
+                if (
+                    parameters["format"] == "upper_alnum_dash"
+                    and not SOURCE_CODE_PATTERN.fullmatch(raw.upper())
+                ):
+                    bad.append(source_mapping.uid)
+                key = (
+                    source_mapping.source_uid,
+                    source_mapping.asset_type,
+                    normalized,
+                )
+                if len(mapping_index[key]) > 1:
+                    duplicates.append(source_mapping.uid)
+            if bad or duplicates:
+                return (
+                    "source_code",
+                    "来源编码格式不一致或规范化后重复",
+                    {
+                        **_source_evidence(asset),
+                        "invalid_mapping_uids": sorted(bad),
+                        "duplicate_mapping_uids": sorted(duplicates),
+                    },
+                )
+            return None
+        if rule_code == "component_parent_resolved":
+            field = parameters["parent_field"]
+            parent_code = _normalize_code(attributes.get(field))
+            mapping = _mapping_for(asset)
+            resolved = bool(
+                mapping
+                and parent_code
+                and mapping_index[
+                    (mapping.source_uid, "device", parent_code)
+                ]
+            )
+            if not resolved:
+                return (
+                    field,
+                    "部件未关联到同一来源中的有效设备",
+                    {
+                        **_source_evidence(asset),
+                        "parent_source_code": _safe_text(attributes.get(field)),
+                    },
+                )
+            return None
+        if rule_code == "fault_code_mapped":
+            field = parameters["fault_field"]
+            code = _normalize_code(attributes.get(field))
+            if not code or code not in code_sets["fault"]:
+                return (
+                    field,
+                    "故障代码未映射到已发布的统一代码集",
+                    {
+                        **_source_evidence(asset),
+                        "fault_code": _safe_text(attributes.get(field)),
+                    },
+                )
+            return None
+        if rule_code == "fault_reason_action_complete":
+            cause_field = parameters["cause_field"]
+            action_field = parameters["action_field"]
+            cause = _normalize_code(attributes.get(cause_field))
+            action = _normalize_code(attributes.get(action_field))
+            missing = []
+            if not cause or cause not in code_sets["cause"]:
+                missing.append(cause_field)
+            if not action or action not in code_sets["action"]:
+                missing.append(action_field)
+            if missing:
+                return (
+                    ",".join(missing),
+                    "故障原因或措施未映射到已发布代码集",
+                    {
+                        **_source_evidence(asset),
+                        "invalid_fields": missing,
+                        "cause_code": _safe_text(attributes.get(cause_field)),
+                        "action_code": _safe_text(attributes.get(action_field)),
+                    },
+                )
+            return None
+        if rule_code == "maintenance_closed_loop":
+            mapping = _mapping_for(asset)
+            device_code = _normalize_code(
+                attributes.get(parameters["device_field"])
+            )
+            fault_code = _normalize_code(
+                attributes.get(parameters["fault_field"])
+            )
+            action_code = _normalize_code(
+                attributes.get(parameters["action_field"])
+            )
+            status = str(
+                attributes.get(parameters["status_field"]) or ""
+            ).strip().casefold()
+            completed_at = attributes.get(parameters["completed_at_field"])
+            missing = []
+            source_uid = mapping.source_uid if mapping else None
+            if not source_uid or not mapping_index[
+                (source_uid, "device", device_code)
+            ]:
+                missing.append(parameters["device_field"])
+            if not source_uid or not mapping_index[
+                (source_uid, "alarm", fault_code)
+            ]:
+                missing.append(parameters["fault_field"])
+            if status not in set(parameters["closed_statuses"]):
+                missing.append(parameters["status_field"])
+            if not _is_iso_datetime(completed_at):
+                missing.append(parameters["completed_at_field"])
+            if not action_code or action_code not in code_sets["action"]:
+                missing.append(parameters["action_field"])
+            if missing:
+                return (
+                    ",".join(sorted(set(missing))),
+                    "维修记录未形成设备、故障、措施和完成时间闭环",
+                    {
+                        **_source_evidence(asset),
+                        "invalid_fields": sorted(set(missing)),
+                        "status": _safe_text(status),
+                    },
+                )
+            return None
+        raise DeviceQualityInvalid("unsupported quality rule code")
+
+    def run(
+        self,
+        *,
+        actor_uid: str,
+        source_uid: str | None = None,
+    ) -> DeviceQualityRunRecord:
+        actor = _required_actor(actor_uid)
+        source = _optional_source_uid(source_uid)
+        active = self.repository.active_version()
+        if active is None:
+            raise DeviceQualityConflict("a published quality policy is required")
+        assets, total = self.repository.load_assets(
+            source_uid=source,
+            limit=MAX_ASSETS + 1,
+        )
+        if total > MAX_ASSETS or len(assets) > MAX_ASSETS:
+            raise DeviceQualityInvalid("quality run exceeds the 5,000 asset boundary")
+        code_sets = {
+            key: {_normalize_code(item) for item in value}
+            for key, value in self.repository.published_code_sets().items()
+        }
+        for code_type in ("fault", "cause", "action"):
+            code_sets.setdefault(code_type, set())
+        mapping_index = self._indexes(assets)
+        run_uid = self.uid_factory()
+        now = self.now_factory()
+        results = []
+        sampled_violations = []
+        violated_by_asset: dict[str, set[str]] = defaultdict(set)
+        applicable_by_asset: dict[str, set[str]] = defaultdict(set)
+        total_violations = 0
+        for rule in active.rules:
+            if not rule["enabled"]:
+                continue
+            applicable_assets = [
+                item
+                for item in assets
+                if _applicable(rule["code"], item, rule["parameters"])
+            ]
+            failures = []
+            for item in applicable_assets:
+                applicable_by_asset[item.uid].add(rule["code"])
+                failure = self._failure(
+                    rule["code"],
+                    item,
+                    rule["parameters"],
+                    mapping_index,
+                    code_sets,
+                )
+                if failure is not None:
+                    failures.append((item, failure))
+                    violated_by_asset[item.uid].add(rule["code"])
+            evaluated_count = len(applicable_assets)
+            violation_count = len(failures)
+            pass_rate = (
+                1.0
+                if evaluated_count == 0
+                else (evaluated_count - violation_count) / evaluated_count
+            )
+            sampled = failures[:MAX_SAMPLES_PER_RULE]
+            for item, (field_name, message, evidence) in sampled:
+                mapping = _mapping_for(item)
+                sampled_violations.append(
+                    DeviceQualityViolationRecord(
+                        uid=self.uid_factory(),
+                        run_uid=run_uid,
+                        rule_code=rule["code"],
+                        severity=rule["severity"],
+                        asset_uid=item.uid,
+                        field_name=field_name,
+                        source_uid=mapping.source_uid if mapping else None,
+                        source_mapping_uid=mapping.uid if mapping else None,
+                        message=message,
+                        evidence=evidence,
+                        created_at=now,
+                        expires_at=now + timedelta(days=SAMPLE_RETENTION_DAYS),
+                    )
+                )
+            results.append(
+                DeviceQualityRuleResultRecord(
+                    uid=self.uid_factory(),
+                    run_uid=run_uid,
+                    rule_code=rule["code"],
+                    severity=rule["severity"],
+                    weight=float(rule["weight"]),
+                    status=(
+                        "not_applicable"
+                        if evaluated_count == 0
+                        else ("passed" if violation_count == 0 else "violated")
+                    ),
+                    evaluated_count=evaluated_count,
+                    violation_count=violation_count,
+                    sampled_count=len(sampled),
+                    pass_rate=round(pass_rate, 6),
+                    weighted_score=round(rule["weight"] * pass_rate, 6),
+                    created_at=now,
+                )
+            )
+            total_violations += violation_count
+        asset_scores = []
+        rule_by_code = {item["code"]: item for item in active.rules}
+        for item in assets:
+            applicable_codes = applicable_by_asset.get(item.uid, set())
+            applicable_weight = sum(
+                rule_by_code[code]["weight"]
+                for code in applicable_codes
+            )
+            failed_weight = sum(
+                rule_by_code[code]["weight"]
+                for code in violated_by_asset.get(item.uid, set())
+            )
+            score = (
+                100.0
+                if applicable_weight == 0
+                else 100.0 * (applicable_weight - failed_weight) / applicable_weight
+            )
+            asset_scores.append(
+                DeviceQualityAssetScoreRecord(
+                    uid=self.uid_factory(),
+                    run_uid=run_uid,
+                    asset_uid=item.uid,
+                    asset_type=item.asset_type,
+                    status=(
+                        "not_applicable"
+                        if applicable_weight == 0
+                        else (
+                            "passed"
+                            if not violated_by_asset.get(item.uid)
+                            else "violated"
+                        )
+                    ),
+                    evaluated_rule_count=len(applicable_codes),
+                    violation_count=len(violated_by_asset.get(item.uid, set())),
+                    score=round(score, 2),
+                    created_at=now,
+                )
+            )
+        overall_score = round(
+            sum(item.weighted_score for item in results),
+            2,
+        )
+        run = DeviceQualityRunRecord(
+            uid=run_uid,
+            policy_version_uid=active.uid,
+            policy_hash=active.content_hash,
+            source_uid=source,
+            status="success",
+            total_assets=len(assets),
+            total_violations=total_violations,
+            score=overall_score,
+            created_by=actor,
+            created_at=now,
+        )
+        try:
+            created = self.repository.create_run(
+                run,
+                results,
+                sampled_violations,
+                asset_scores,
+            )
+            self.commit()
+            return created
+        except Exception:
+            self.rollback()
+            raise
+
+    def runs(self, *, page: int, page_size: int):
+        page = _positive_integer(page, "page")
+        page_size = _positive_integer(page_size, "page_size")
+        if page_size > 100:
+            raise DeviceQualityInvalid("page_size exceeds 100")
+        return self.repository.list_runs(page=page, page_size=page_size)
+
+    def get_run(self, run_uid: str):
+        run = self.repository.get_run(run_uid)
+        if run is None:
+            raise DeviceQualityNotFound("quality run was not found")
+        return run, tuple(self.repository.list_rule_results(run_uid))
+
+    def violations(
+        self,
+        run_uid: str,
+        *,
+        rule_code: str | None,
+        page: int,
+        page_size: int,
+    ):
+        if self.repository.get_run(run_uid) is None:
+            raise DeviceQualityNotFound("quality run was not found")
+        if rule_code is not None and rule_code not in RULE_CODES:
+            raise DeviceQualityInvalid("unsupported quality rule code")
+        page = _positive_integer(page, "page")
+        page_size = _positive_integer(page_size, "page_size")
+        if page_size > 100:
+            raise DeviceQualityInvalid("page_size exceeds 100")
+        return self.repository.list_violations(
+            run_uid,
+            rule_code=rule_code,
+            page=page,
+            page_size=page_size,
+        )
+
+    def asset_scores(self, run_uid: str, *, page: int, page_size: int):
+        if self.repository.get_run(run_uid) is None:
+            raise DeviceQualityNotFound("quality run was not found")
+        page = _positive_integer(page, "page")
+        page_size = _positive_integer(page_size, "page_size")
+        if page_size > 1_000:
+            raise DeviceQualityInvalid("page_size exceeds 1,000")
+        return self.repository.list_asset_scores(
+            run_uid,
+            page=page,
+            page_size=page_size,
+        )

+ 478 - 0
app/core/data_research/device_quality_repository.py

@@ -0,0 +1,478 @@
+"""SQLAlchemy persistence for device-domain quality policy and evidence."""
+
+from __future__ import annotations
+
+from sqlalchemy import func, text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.data_research.device_quality import (
+    DeviceQualityAssetScoreRecord,
+    DeviceQualityAssetSnapshot,
+    DeviceQualityPolicyVersionRecord,
+    DeviceQualityRuleResultRecord,
+    DeviceQualityRunRecord,
+    DeviceQualitySourceMapping,
+    DeviceQualityViolationRecord,
+)
+from app.models.data_research import (
+    DeviceAsset,
+    DeviceAssetSourceMapping,
+    DeviceQualityAssetScore,
+    DeviceQualityProfile,
+    DeviceQualityProfileVersion,
+    DeviceQualityRuleResult,
+    DeviceQualityRun,
+    DeviceQualityViolationSample,
+    DeviceSemanticCode,
+)
+
+PROFILE_CODE = "DEVICE_QUALITY"
+
+
+class SqlAlchemyDeviceQualityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @property
+    def profile_uid(self):
+        model = (
+            self.session.query(DeviceQualityProfile)
+            .filter_by(code=PROFILE_CODE)
+            .first()
+        )
+        return str(model.uid) if model is not None else None
+
+    @staticmethod
+    def _version(model):
+        return DeviceQualityPolicyVersionRecord(
+            uid=str(model.uid),
+            profile_uid=str(model.profile_uid),
+            version=int(model.version),
+            status=model.status,
+            rules=tuple(dict(item) for item in (model.rules or [])),
+            content_hash=model.content_hash,
+            created_by=model.created_by,
+            created_at=model.created_at,
+            published_by=model.published_by,
+            published_at=model.published_at,
+        )
+
+    @staticmethod
+    def _run(model):
+        return DeviceQualityRunRecord(
+            uid=str(model.uid),
+            policy_version_uid=str(model.policy_version_uid),
+            policy_hash=model.policy_hash,
+            source_uid=str(model.source_uid) if model.source_uid else None,
+            status=model.status,
+            total_assets=int(model.total_assets),
+            total_violations=int(model.total_violations),
+            score=float(model.score),
+            created_by=model.created_by,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _result(model):
+        return DeviceQualityRuleResultRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            rule_code=model.rule_code,
+            severity=model.severity,
+            weight=float(model.weight),
+            status=model.status,
+            evaluated_count=int(model.evaluated_count),
+            violation_count=int(model.violation_count),
+            sampled_count=int(model.sampled_count),
+            pass_rate=float(model.pass_rate),
+            weighted_score=float(model.weighted_score),
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _violation(model):
+        return DeviceQualityViolationRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            rule_code=model.rule_code,
+            severity=model.severity,
+            asset_uid=str(model.asset_uid),
+            field_name=model.field_name,
+            source_uid=str(model.source_uid) if model.source_uid else None,
+            source_mapping_uid=(
+                str(model.source_mapping_uid)
+                if model.source_mapping_uid
+                else None
+            ),
+            message=model.message,
+            evidence=dict(model.evidence or {}),
+            created_at=model.created_at,
+            expires_at=model.expires_at,
+        )
+
+    @staticmethod
+    def _asset_score(model):
+        return DeviceQualityAssetScoreRecord(
+            uid=str(model.uid),
+            run_uid=str(model.run_uid),
+            asset_uid=str(model.asset_uid),
+            asset_type=model.asset_type,
+            status=model.status,
+            evaluated_rule_count=int(model.evaluated_rule_count),
+            violation_count=int(model.violation_count),
+            score=float(model.score),
+            created_at=model.created_at,
+        )
+
+    def ensure_profile(self, *, name, actor_uid):
+        self.session.execute(
+            text(
+                "SELECT pg_advisory_xact_lock("
+                "hashtext('device-quality:profile'))"
+            )
+        )
+        model = (
+            self.session.query(DeviceQualityProfile)
+            .filter_by(code=PROFILE_CODE)
+            .first()
+        )
+        if model is None:
+            model = DeviceQualityProfile(
+                uid=new_governance_uid(),
+                code=PROFILE_CODE,
+                name=str(name),
+                created_by=str(actor_uid),
+            )
+            self.session.add(model)
+            self.session.flush()
+        return str(model.uid)
+
+    def latest_version(self):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(DeviceQualityProfile.code == PROFILE_CODE)
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def find_version_by_hash(self, content_hash):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(
+                DeviceQualityProfile.code == PROFILE_CODE,
+                DeviceQualityProfileVersion.content_hash
+                == str(content_hash),
+            )
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def create_version(self, record):
+        model = DeviceQualityProfileVersion(
+            uid=record.uid,
+            profile_uid=record.profile_uid,
+            version=record.version,
+            status=record.status,
+            rules=list(record.rules),
+            content_hash=record.content_hash,
+            created_by=record.created_by,
+            created_at=record.created_at,
+            published_by=record.published_by,
+            published_at=record.published_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        return self._version(model)
+
+    def get_version(self, version_uid, *, for_update=False):
+        query = self.session.query(DeviceQualityProfileVersion).filter_by(
+            uid=str(version_uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._version(model) if model is not None else None
+
+    def list_versions(self):
+        models = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(DeviceQualityProfile.code == PROFILE_CODE)
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .all()
+        )
+        return [self._version(model) for model in models]
+
+    def active_version(self):
+        model = (
+            self.session.query(DeviceQualityProfileVersion)
+            .join(
+                DeviceQualityProfile,
+                DeviceQualityProfile.uid
+                == DeviceQualityProfileVersion.profile_uid,
+            )
+            .filter(
+                DeviceQualityProfile.code == PROFILE_CODE,
+                DeviceQualityProfileVersion.status == "published",
+            )
+            .order_by(DeviceQualityProfileVersion.version.desc())
+            .first()
+        )
+        return self._version(model) if model is not None else None
+
+    def publish_version(self, record, *, actor_uid, published_at):
+        self.session.query(DeviceQualityProfileVersion).filter(
+            DeviceQualityProfileVersion.profile_uid == record.profile_uid,
+            DeviceQualityProfileVersion.status == "published",
+        ).update(
+            {"status": "superseded"},
+            synchronize_session=False,
+        )
+        model = self.session.get(
+            DeviceQualityProfileVersion,
+            str(record.uid),
+        )
+        model.status = "published"
+        model.published_by = str(actor_uid)
+        model.published_at = published_at
+        self.session.flush()
+        return self._version(model)
+
+    @staticmethod
+    def _asset(model, mappings):
+        return DeviceQualityAssetSnapshot(
+            uid=str(model.uid),
+            asset_type=model.asset_type,
+            name=model.name,
+            status=model.status,
+            current_version=int(model.current_version),
+            location=model.location,
+            organization=model.organization,
+            responsible_person=model.responsible_person,
+            attributes=dict(model.attributes or {}),
+            mappings=tuple(
+                DeviceQualitySourceMapping(
+                    uid=str(item.uid),
+                    source_uid=str(item.source_uid),
+                    source_entity=item.source_entity,
+                    asset_type=item.asset_type,
+                    source_code=item.source_code,
+                )
+                for item in mappings
+            ),
+        )
+
+    def load_assets(self, *, source_uid, limit):
+        query = self.session.query(DeviceAsset).filter_by(status="active")
+        if source_uid is not None:
+            query = query.join(
+                DeviceAssetSourceMapping,
+                DeviceAssetSourceMapping.asset_uid == DeviceAsset.uid,
+            ).filter(
+                DeviceAssetSourceMapping.source_uid == str(source_uid)
+            )
+        total = (
+            query.with_entities(func.count(func.distinct(DeviceAsset.uid)))
+            .scalar()
+            or 0
+        )
+        models = (
+            query.distinct()
+            .order_by(DeviceAsset.uid.asc())
+            .limit(int(limit))
+            .all()
+        )
+        asset_uids = [str(model.uid) for model in models]
+        mapping_models = (
+            self.session.query(DeviceAssetSourceMapping)
+            .filter(DeviceAssetSourceMapping.asset_uid.in_(asset_uids))
+            .order_by(
+                DeviceAssetSourceMapping.asset_uid.asc(),
+                DeviceAssetSourceMapping.uid.asc(),
+            )
+            .all()
+            if asset_uids
+            else []
+        )
+        by_asset = {}
+        for item in mapping_models:
+            by_asset.setdefault(str(item.asset_uid), []).append(item)
+        return [
+            self._asset(model, by_asset.get(str(model.uid), []))
+            for model in models
+        ], int(total)
+
+    def published_code_sets(self):
+        rows = (
+            self.session.query(
+                DeviceSemanticCode.code_type,
+                DeviceSemanticCode.canonical_code,
+            )
+            .filter(DeviceSemanticCode.status == "published")
+            .all()
+        )
+        result = {"fault": set(), "cause": set(), "action": set()}
+        for code_type, canonical_code in rows:
+            result[str(code_type)].add(str(canonical_code))
+        return result
+
+    def create_run(self, run, results, violations, asset_scores):
+        model = DeviceQualityRun(
+            uid=run.uid,
+            policy_version_uid=run.policy_version_uid,
+            policy_hash=run.policy_hash,
+            source_uid=run.source_uid,
+            status=run.status,
+            total_assets=run.total_assets,
+            total_violations=run.total_violations,
+            score=run.score,
+            created_by=run.created_by,
+            created_at=run.created_at,
+        )
+        self.session.add(model)
+        self.session.flush()
+        self.session.add_all(
+            [
+                DeviceQualityRuleResult(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    rule_code=item.rule_code,
+                    severity=item.severity,
+                    weight=item.weight,
+                    status=item.status,
+                    evaluated_count=item.evaluated_count,
+                    violation_count=item.violation_count,
+                    sampled_count=item.sampled_count,
+                    pass_rate=item.pass_rate,
+                    weighted_score=item.weighted_score,
+                    created_at=item.created_at,
+                )
+                for item in results
+            ]
+        )
+        self.session.add_all(
+            [
+                DeviceQualityViolationSample(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    rule_code=item.rule_code,
+                    severity=item.severity,
+                    asset_uid=item.asset_uid,
+                    field_name=item.field_name,
+                    source_uid=item.source_uid,
+                    source_mapping_uid=item.source_mapping_uid,
+                    message=item.message,
+                    evidence=item.evidence,
+                    created_at=item.created_at,
+                    expires_at=item.expires_at,
+                )
+                for item in violations
+            ]
+        )
+        self.session.add_all(
+            [
+                DeviceQualityAssetScore(
+                    uid=item.uid,
+                    run_uid=item.run_uid,
+                    asset_uid=item.asset_uid,
+                    asset_type=item.asset_type,
+                    status=item.status,
+                    evaluated_rule_count=item.evaluated_rule_count,
+                    violation_count=item.violation_count,
+                    score=item.score,
+                    created_at=item.created_at,
+                )
+                for item in asset_scores
+            ]
+        )
+        self.session.flush()
+        return self._run(model)
+
+    def list_runs(self, *, page, page_size):
+        query = self.session.query(DeviceQualityRun)
+        total = query.with_entities(
+            func.count(DeviceQualityRun.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityRun.created_at.desc(),
+                DeviceQualityRun.uid.desc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._run(model) for model in models], int(total)
+
+    def get_run(self, run_uid):
+        model = self.session.get(DeviceQualityRun, str(run_uid))
+        return self._run(model) if model is not None else None
+
+    def list_rule_results(self, run_uid):
+        models = (
+            self.session.query(DeviceQualityRuleResult)
+            .filter_by(run_uid=str(run_uid))
+            .order_by(DeviceQualityRuleResult.rule_code.asc())
+            .all()
+        )
+        return [self._result(model) for model in models]
+
+    def list_violations(
+        self,
+        run_uid,
+        *,
+        rule_code,
+        page,
+        page_size,
+    ):
+        query = self.session.query(DeviceQualityViolationSample).filter_by(
+            run_uid=str(run_uid)
+        )
+        if rule_code is not None:
+            query = query.filter_by(rule_code=str(rule_code))
+        total = query.with_entities(
+            func.count(DeviceQualityViolationSample.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityViolationSample.rule_code.asc(),
+                DeviceQualityViolationSample.asset_uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._violation(model) for model in models], int(total)
+
+    def list_asset_scores(self, run_uid, *, page, page_size):
+        query = self.session.query(DeviceQualityAssetScore).filter_by(
+            run_uid=str(run_uid)
+        )
+        total = query.with_entities(
+            func.count(DeviceQualityAssetScore.uid)
+        ).scalar() or 0
+        models = (
+            query.order_by(
+                DeviceQualityAssetScore.score.asc(),
+                DeviceQualityAssetScore.asset_uid.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._asset_score(model) for model in models], int(total)

+ 262 - 0
app/core/data_research/device_semantic_repository.py

@@ -0,0 +1,262 @@
+from __future__ import annotations
+
+from sqlalchemy import func, or_
+
+from app.core.data_research.device_semantics import (
+    DeviceSemanticCodeRecord,
+    DeviceSemanticCodeReviewRecord,
+    DeviceSemanticCodeVersionRecord,
+)
+from app.models.data_research import (
+    DeviceSemanticCode,
+    DeviceSemanticCodeReview,
+    DeviceSemanticCodeVersion,
+    OntologyModel,
+)
+
+
+class SqlAlchemyDeviceSemanticCodeRepository:
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _record(model):
+        return DeviceSemanticCodeRecord(
+            uid=str(model.uid),
+            ontology_uid=str(model.ontology_uid),
+            code_type=model.code_type,
+            canonical_code=model.canonical_code,
+            canonical_name=model.canonical_name,
+            definition=model.definition,
+            status=model.status,
+            current_version=int(model.current_version),
+            source_mappings=tuple(
+                dict(item) for item in (model.source_mappings or ())
+            ),
+            evidence_uids=tuple(model.evidence_uids or ()),
+            suggestion_source=model.suggestion_source,
+            confidence=model.confidence,
+            created_by=model.created_by,
+            updated_by=model.updated_by,
+            created_at=model.created_at,
+            updated_at=model.updated_at,
+        )
+
+    @staticmethod
+    def _version_record(model):
+        return DeviceSemanticCodeVersionRecord(
+            uid=str(model.uid),
+            code_uid=str(model.code_uid),
+            version=int(model.version),
+            snapshot=dict(model.snapshot or {}),
+            created_by=model.created_by,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _review_record(model):
+        return DeviceSemanticCodeReviewRecord(
+            uid=str(model.uid),
+            code_uid=str(model.code_uid),
+            version=int(model.version),
+            decision=model.decision,
+            reason=model.reason,
+            actor_uid=model.actor_uid,
+            created_at=model.created_at,
+        )
+
+    @staticmethod
+    def _model(record):
+        return DeviceSemanticCode(
+            uid=record.uid,
+            ontology_uid=record.ontology_uid,
+            code_type=record.code_type,
+            canonical_code=record.canonical_code,
+            canonical_name=record.canonical_name,
+            definition=record.definition,
+            status=record.status,
+            current_version=record.current_version,
+            source_mappings=list(record.source_mappings),
+            evidence_uids=list(record.evidence_uids),
+            suggestion_source=record.suggestion_source,
+            confidence=record.confidence,
+            created_by=record.created_by,
+            updated_by=record.updated_by,
+            created_at=record.created_at,
+            updated_at=record.updated_at,
+        )
+
+    @staticmethod
+    def _version_model(record):
+        return DeviceSemanticCodeVersion(
+            uid=record.uid,
+            code_uid=record.code_uid,
+            version=record.version,
+            snapshot=record.snapshot,
+            created_by=record.created_by,
+            created_at=record.created_at,
+        )
+
+    def ontology_exists(self, ontology_uid):
+        return (
+            self.session.query(OntologyModel.uid)
+            .filter_by(uid=str(ontology_uid))
+            .first()
+            is not None
+        )
+
+    def is_device_ontology(self, ontology_uid):
+        return (
+            self.session.query(OntologyModel.uid)
+            .filter_by(
+                uid=str(ontology_uid),
+                code="DEVICE_SEMANTIC",
+            )
+            .first()
+            is not None
+        )
+
+    def find_code(self, ontology_uid, code_type, canonical_code):
+        model = (
+            self.session.query(DeviceSemanticCode)
+            .filter_by(
+                ontology_uid=str(ontology_uid),
+                code_type=str(code_type),
+                canonical_code=str(canonical_code),
+            )
+            .first()
+        )
+        return self._record(model) if model is not None else None
+
+    def get_code(self, code_uid, *, for_update=False):
+        query = self.session.query(DeviceSemanticCode).filter_by(
+            uid=str(code_uid)
+        )
+        if for_update:
+            query = query.with_for_update()
+        model = query.first()
+        return self._record(model) if model is not None else None
+
+    def create_code(self, record, version):
+        model = self._model(record)
+        self.session.add(model)
+        self.session.add(self._version_model(version))
+        self.session.flush()
+        return self._record(model)
+
+    def revise_code(self, record, version):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        for field in (
+            "canonical_name",
+            "definition",
+            "status",
+            "current_version",
+            "source_mappings",
+            "evidence_uids",
+            "suggestion_source",
+            "confidence",
+            "updated_by",
+            "updated_at",
+        ):
+            value = getattr(record, field)
+            if field in {"source_mappings", "evidence_uids"}:
+                value = list(value)
+            setattr(model, field, value)
+        self.session.add(self._version_model(version))
+        self.session.flush()
+        return self._record(model)
+
+    def set_status(self, record, status, *, updated_at, updated_by):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        model.status = status
+        model.updated_at = updated_at
+        model.updated_by = updated_by
+        self.session.flush()
+        return self._record(model)
+
+    def review_code(self, record, review, status):
+        model = self.session.get(DeviceSemanticCode, str(record.uid))
+        review_model = DeviceSemanticCodeReview(
+            uid=review.uid,
+            code_uid=review.code_uid,
+            version=review.version,
+            decision=review.decision,
+            reason=review.reason,
+            actor_uid=review.actor_uid,
+            created_at=review.created_at,
+        )
+        model.status = status
+        model.updated_at = review.created_at
+        model.updated_by = review.actor_uid
+        self.session.add(review_model)
+        self.session.flush()
+        return self._record(model), self._review_record(review_model)
+
+    @staticmethod
+    def _filtered(query, filters):
+        if filters.get("ontology_uid"):
+            query = query.filter(
+                DeviceSemanticCode.ontology_uid
+                == str(filters["ontology_uid"])
+            )
+        if filters.get("code_type"):
+            query = query.filter(
+                DeviceSemanticCode.code_type == str(filters["code_type"])
+            )
+        if filters.get("status"):
+            query = query.filter(
+                DeviceSemanticCode.status == str(filters["status"])
+            )
+        if filters.get("keyword"):
+            pattern = f"%{filters['keyword']}%"
+            query = query.filter(
+                or_(
+                    DeviceSemanticCode.canonical_code.ilike(pattern),
+                    DeviceSemanticCode.canonical_name.ilike(pattern),
+                    DeviceSemanticCode.definition.ilike(pattern),
+                )
+            )
+        return query
+
+    def search(self, filters, *, page, page_size):
+        base = self._filtered(
+            self.session.query(DeviceSemanticCode),
+            filters,
+        )
+        total = base.with_entities(
+            func.count(DeviceSemanticCode.uid)
+        ).scalar() or 0
+        models = (
+            base.order_by(
+                DeviceSemanticCode.code_type.asc(),
+                DeviceSemanticCode.canonical_code.asc(),
+            )
+            .offset((int(page) - 1) * int(page_size))
+            .limit(int(page_size))
+            .all()
+        )
+        return [self._record(model) for model in models], int(total)
+
+    def list_versions(self, code_uid):
+        return [
+            self._version_record(model)
+            for model in self.session.query(DeviceSemanticCodeVersion)
+            .filter_by(code_uid=str(code_uid))
+            .order_by(DeviceSemanticCodeVersion.version.desc())
+            .all()
+        ]
+
+    def list_reviews(self, code_uid):
+        return [
+            self._review_record(model)
+            for model in self.session.query(DeviceSemanticCodeReview)
+            .filter_by(code_uid=str(code_uid))
+            .order_by(
+                DeviceSemanticCodeReview.created_at.desc(),
+                DeviceSemanticCodeReview.uid.desc(),
+            )
+            .all()
+        ]
+
+
+__all__ = ["SqlAlchemyDeviceSemanticCodeRepository"]

+ 969 - 0
app/core/data_research/device_semantics.py

@@ -0,0 +1,969 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any
+from uuid import UUID
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+from app.core.data_research.errors import (
+    DeviceSemanticConflict,
+    DeviceSemanticForbidden,
+    DeviceSemanticInvalid,
+    DeviceSemanticNotFound,
+)
+
+DEVICE_ONTOLOGY_CODE = "DEVICE_SEMANTIC"
+REQUIRED_CLASS_UIDS = (
+    "action",
+    "alarm",
+    "cause",
+    "component",
+    "device",
+    "fault",
+    "location",
+    "maintenance_record",
+    "measurement_point",
+    "organization",
+    "person",
+)
+REQUIRED_RELATION_UIDS = (
+    "alarm.classified_as",
+    "alarm.raised_by",
+    "component.part_of",
+    "device.located_at",
+    "device.owned_by",
+    "device.responsible_to",
+    "fault.caused_by",
+    "maintenance_record.maintains",
+    "maintenance_record.uses_action",
+    "measurement_point.monitors",
+)
+REQUIRED_MAPPING_UIDS = (
+    "device.platform_uid",
+    "device.source_code",
+)
+CODE_TYPES = frozenset({"fault", "cause", "action"})
+CODE_STATUSES = frozenset(
+    {"draft", "in_review", "published", "rejected", "retired"}
+)
+SUGGESTION_SOURCES = frozenset({"manual", "rule", "ai"})
+SEMANTIC_SECRET_KEYS = frozenset(
+    {
+        "password",
+        "passwd",
+        "credential",
+        "credentials",
+        "token",
+        "api_key",
+        "authorization",
+        "connection_string",
+        "connection_url",
+        "encrypted_payload",
+        "phone",
+        "mobile",
+        "email",
+        "contact",
+        "contact_phone",
+        "contact_email",
+        "id_number",
+        "personal_address",
+    }
+)
+
+
+@dataclass(frozen=True)
+class DeviceSemanticProfile:
+    ready_to_publish: bool
+    class_count: int
+    relation_count: int
+    mapping_count: int
+    missing_classes: tuple[str, ...]
+    missing_relations: tuple[str, ...]
+    missing_mappings: tuple[str, ...]
+
+
+@dataclass(frozen=True)
+class DeviceSemanticBootstrapResult:
+    ontology: Any
+    version: Any
+    profile: DeviceSemanticProfile
+    created: bool
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeRecord:
+    uid: str
+    ontology_uid: str
+    code_type: str
+    canonical_code: str
+    canonical_name: str
+    definition: str | None
+    status: str
+    current_version: int
+    source_mappings: tuple[dict[str, str], ...]
+    evidence_uids: tuple[str, ...]
+    suggestion_source: str
+    confidence: float | None
+    created_by: str | None
+    updated_by: str | None
+    created_at: datetime | None = None
+    updated_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeVersionRecord:
+    uid: str
+    code_uid: str
+    version: int
+    snapshot: dict[str, Any]
+    created_by: str | None
+    created_at: datetime | None = None
+
+
+@dataclass(frozen=True)
+class DeviceSemanticCodeReviewRecord:
+    uid: str
+    code_uid: str
+    version: int
+    decision: str
+    reason: str
+    actor_uid: str
+    created_at: datetime | None = None
+
+
+def _uuid(value: Any, field: str) -> str:
+    try:
+        return str(UUID(str(value)))
+    except (AttributeError, TypeError, ValueError) as exc:
+        raise DeviceSemanticInvalid(f"{field} must be a UUID") from exc
+
+
+def _required_text(
+    payload: dict[str, Any],
+    field: str,
+    *,
+    maximum: int,
+) -> str:
+    value = str(payload.get(field) or "").strip()
+    if not value:
+        raise DeviceSemanticInvalid(f"{field} is required")
+    if len(value) > maximum:
+        raise DeviceSemanticInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return value
+
+
+def _optional_text(
+    payload: dict[str, Any],
+    field: str,
+    *,
+    maximum: int,
+) -> str | None:
+    value = str(payload.get(field) or "").strip()
+    if not value:
+        return None
+    if len(value) > maximum:
+        raise DeviceSemanticInvalid(
+            f"{field} exceeds {maximum} characters"
+        )
+    return value
+
+
+def _reject_semantic_secrets(value: Any) -> None:
+    if isinstance(value, dict):
+        for key, item in value.items():
+            if str(key).strip().casefold() in SEMANTIC_SECRET_KEYS:
+                raise DeviceSemanticInvalid(
+                    "secret values are not allowed in semantic codes"
+                )
+            _reject_semantic_secrets(item)
+    elif isinstance(value, (list, tuple)):
+        for item in value:
+            _reject_semantic_secrets(item)
+
+
+def _source_mappings(value: Any) -> tuple[dict[str, str], ...]:
+    raw_items = value or ()
+    if not isinstance(raw_items, (list, tuple)):
+        raise DeviceSemanticInvalid("source_mappings must be a list")
+    if len(raw_items) > 100:
+        raise DeviceSemanticInvalid(
+            "source_mappings exceed the 100 item boundary"
+        )
+    _reject_semantic_secrets(raw_items)
+    items = []
+    identities = set()
+    for raw in raw_items:
+        if not isinstance(raw, dict):
+            raise DeviceSemanticInvalid(
+                "source mapping must be an object"
+            )
+        item = {
+            "source_uid": _uuid(raw.get("source_uid"), "source_uid"),
+            "source_code": _required_text(
+                raw,
+                "source_code",
+                maximum=300,
+            ),
+        }
+        identity = (item["source_uid"], item["source_code"])
+        if identity in identities:
+            raise DeviceSemanticInvalid("duplicate source mapping")
+        identities.add(identity)
+        items.append(item)
+    return tuple(items)
+
+
+def _evidence(value: Any) -> tuple[str, ...]:
+    raw_items = value or ()
+    if not isinstance(raw_items, (list, tuple)):
+        raise DeviceSemanticInvalid("evidence_uids must be a list")
+    if len(raw_items) > 100:
+        raise DeviceSemanticInvalid(
+            "evidence_uids exceed the 100 item boundary"
+        )
+    items = tuple(str(item).strip() for item in raw_items)
+    if any(not item or len(item) > 120 for item in items):
+        raise DeviceSemanticInvalid("evidence uid is invalid")
+    if len(set(items)) != len(items):
+        raise DeviceSemanticInvalid("duplicate evidence uid")
+    return items
+
+
+def _proposal(payload: dict[str, Any]):
+    suggestion_source = str(
+        payload.get("suggestion_source") or "manual"
+    ).strip()
+    if suggestion_source not in SUGGESTION_SOURCES:
+        raise DeviceSemanticInvalid("suggestion_source is not supported")
+    evidence_uids = _evidence(payload.get("evidence_uids"))
+    if suggestion_source != "manual" and not evidence_uids:
+        raise DeviceSemanticInvalid(
+            "rule and AI suggestions require evidence"
+        )
+    raw_confidence = payload.get("confidence")
+    confidence = None
+    if raw_confidence not in (None, ""):
+        try:
+            confidence = float(raw_confidence)
+        except (TypeError, ValueError) as exc:
+            raise DeviceSemanticInvalid(
+                "confidence must be numeric"
+            ) from exc
+        if confidence < 0 or confidence > 1:
+            raise DeviceSemanticInvalid(
+                "confidence must be between 0 and 1"
+            )
+    if suggestion_source == "ai" and confidence is None:
+        raise DeviceSemanticInvalid(
+            "AI suggestion confidence is required"
+        )
+    return suggestion_source, evidence_uids, confidence
+
+
+def _semantic_snapshot(
+    payload: dict[str, Any],
+    *,
+    existing: DeviceSemanticCodeRecord | None = None,
+):
+    if not isinstance(payload, dict):
+        raise DeviceSemanticInvalid("payload must be an object")
+    _reject_semantic_secrets(payload)
+    if existing is None:
+        ontology_uid = _uuid(
+            payload.get("ontology_uid"),
+            "ontology_uid",
+        )
+        code_type = str(payload.get("code_type") or "").strip()
+        if code_type not in CODE_TYPES:
+            raise DeviceSemanticInvalid("code_type is not supported")
+        canonical_code = _required_text(
+            payload,
+            "canonical_code",
+            maximum=120,
+        ).upper()
+    else:
+        ontology_uid = existing.ontology_uid
+        code_type = existing.code_type
+        canonical_code = existing.canonical_code
+    effective_payload = dict(payload)
+    if existing is not None:
+        effective_payload.setdefault(
+            "canonical_name",
+            existing.canonical_name,
+        )
+        effective_payload.setdefault("definition", existing.definition)
+    suggestion_source, evidence_uids, confidence = _proposal(
+        {
+            "suggestion_source": payload.get(
+                "suggestion_source",
+                existing.suggestion_source if existing else "manual",
+            ),
+            "evidence_uids": payload.get(
+                "evidence_uids",
+                list(existing.evidence_uids) if existing else [],
+            ),
+            "confidence": payload.get(
+                "confidence",
+                existing.confidence if existing else None,
+            ),
+        }
+    )
+    source_mappings = _source_mappings(
+        payload.get(
+            "source_mappings",
+            list(existing.source_mappings) if existing else [],
+        )
+    )
+    snapshot = {
+        "ontology_uid": ontology_uid,
+        "code_type": code_type,
+        "canonical_code": canonical_code,
+        "canonical_name": _required_text(
+            effective_payload,
+            "canonical_name",
+            maximum=300,
+        ),
+        "definition": _optional_text(
+            effective_payload,
+            "definition",
+            maximum=2000,
+        ),
+        "source_mappings": [dict(item) for item in source_mappings],
+        "evidence_uids": list(evidence_uids),
+        "suggestion_source": suggestion_source,
+        "confidence": confidence,
+    }
+    return snapshot
+
+
+def _version(value: Any, field: str = "expected_version") -> int:
+    try:
+        version = int(value)
+    except (TypeError, ValueError) as exc:
+        raise DeviceSemanticInvalid(f"{field} must be an integer") from exc
+    if version < 0:
+        raise DeviceSemanticInvalid(f"{field} must not be negative")
+    return version
+
+
+def _record_from_snapshot(
+    *,
+    uid: str,
+    snapshot: dict[str, Any],
+    status: str,
+    current_version: int,
+    actor_uid: str | None,
+    created_at: datetime,
+    updated_at: datetime,
+    created_by: str | None = None,
+) -> DeviceSemanticCodeRecord:
+    return DeviceSemanticCodeRecord(
+        uid=uid,
+        ontology_uid=snapshot["ontology_uid"],
+        code_type=snapshot["code_type"],
+        canonical_code=snapshot["canonical_code"],
+        canonical_name=snapshot["canonical_name"],
+        definition=snapshot["definition"],
+        status=status,
+        current_version=current_version,
+        source_mappings=tuple(
+            dict(item) for item in snapshot["source_mappings"]
+        ),
+        evidence_uids=tuple(snapshot["evidence_uids"]),
+        suggestion_source=snapshot["suggestion_source"],
+        confidence=snapshot["confidence"],
+        created_by=created_by if created_by is not None else actor_uid,
+        updated_by=actor_uid,
+        created_at=created_at,
+        updated_at=updated_at,
+    )
+
+
+class DeviceSemanticCodeService:
+    def __init__(
+        self,
+        repository,
+        *,
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        review_authorizer: Callable[[str, str], Any] = (
+            lambda _ontology_uid, _actor_uid: None
+        ),
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.repository = repository
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.review_authorizer = review_authorizer
+        self.commit = commit
+        self.rollback = rollback
+
+    @staticmethod
+    def _assert_expected_version(record, expected_version):
+        expected = _version(expected_version)
+        if record.current_version != expected:
+            raise DeviceSemanticConflict(
+                "semantic code version has changed"
+            )
+
+    def _get(self, code_uid, *, for_update=False):
+        uid = _uuid(code_uid, "code_uid")
+        record = self.repository.get_code(uid, for_update=for_update)
+        if record is None:
+            raise DeviceSemanticNotFound("semantic code was not found")
+        return record
+
+    def create(self, payload, actor_uid):
+        snapshot = _semantic_snapshot(payload)
+        if not self.repository.ontology_exists(snapshot["ontology_uid"]):
+            raise DeviceSemanticNotFound("ontology was not found")
+        if not self.repository.is_device_ontology(
+            snapshot["ontology_uid"]
+        ):
+            raise DeviceSemanticInvalid(
+                "semantic codes must belong to the device ontology"
+            )
+        existing = self.repository.find_code(
+            snapshot["ontology_uid"],
+            snapshot["code_type"],
+            snapshot["canonical_code"],
+        )
+        if existing is not None:
+            raise DeviceSemanticConflict("semantic code already exists")
+        created_at = self.now_factory()
+        code_uid = self.uid_factory()
+        record = _record_from_snapshot(
+            uid=code_uid,
+            snapshot=snapshot,
+            status="draft",
+            current_version=1,
+            actor_uid=actor_uid,
+            created_at=created_at,
+            updated_at=created_at,
+        )
+        version = DeviceSemanticCodeVersionRecord(
+            uid=self.uid_factory(),
+            code_uid=code_uid,
+            version=1,
+            snapshot=dict(snapshot),
+            created_by=actor_uid,
+            created_at=created_at,
+        )
+        try:
+            created = self.repository.create_code(record, version)
+            self.commit()
+            return created
+        except Exception:
+            self.rollback()
+            raise
+
+    def revise(
+        self,
+        code_uid,
+        payload,
+        *,
+        expected_version,
+        actor_uid,
+    ):
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(record, expected_version)
+        if record.status not in {"draft", "rejected"}:
+            raise DeviceSemanticInvalid(
+                "only draft or rejected semantic codes can be revised"
+            )
+        snapshot = _semantic_snapshot(payload, existing=record)
+        changed_at = self.now_factory()
+        next_version = record.current_version + 1
+        revised = _record_from_snapshot(
+            uid=record.uid,
+            snapshot=snapshot,
+            status="draft",
+            current_version=next_version,
+            actor_uid=actor_uid,
+            created_by=record.created_by,
+            created_at=record.created_at,
+            updated_at=changed_at,
+        )
+        version = DeviceSemanticCodeVersionRecord(
+            uid=self.uid_factory(),
+            code_uid=record.uid,
+            version=next_version,
+            snapshot=dict(snapshot),
+            created_by=actor_uid,
+            created_at=changed_at,
+        )
+        try:
+            result = self.repository.revise_code(revised, version)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def submit(self, code_uid, *, expected_version, actor_uid):
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(record, expected_version)
+        if record.status != "draft":
+            raise DeviceSemanticInvalid(
+                "only draft semantic codes can enter in_review"
+            )
+        changed_at = self.now_factory()
+        try:
+            result = self.repository.set_status(
+                record,
+                "in_review",
+                updated_at=changed_at,
+                updated_by=actor_uid,
+            )
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def review(self, code_uid, payload, *, actor_uid):
+        if not isinstance(payload, dict):
+            raise DeviceSemanticInvalid("payload must be an object")
+        decision = str(payload.get("decision") or "").strip()
+        if decision not in {"approve", "reject"}:
+            raise DeviceSemanticInvalid(
+                "decision must be approve or reject"
+            )
+        reason = _optional_text(payload, "reason", maximum=1000) or ""
+        if decision == "reject" and not reason:
+            raise DeviceSemanticInvalid("rejection reason is required")
+        record = self._get(code_uid, for_update=True)
+        self._assert_expected_version(
+            record,
+            payload.get("expected_version"),
+        )
+        if record.status != "in_review":
+            raise DeviceSemanticInvalid(
+                "only in_review semantic codes can be reviewed"
+            )
+        self.review_authorizer(record.ontology_uid, str(actor_uid))
+        reviewed_at = self.now_factory()
+        review = DeviceSemanticCodeReviewRecord(
+            uid=self.uid_factory(),
+            code_uid=record.uid,
+            version=record.current_version,
+            decision=decision,
+            reason=reason,
+            actor_uid=str(actor_uid),
+            created_at=reviewed_at,
+        )
+        status = "published" if decision == "approve" else "rejected"
+        try:
+            result = self.repository.review_code(record, review, status)
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def search(self, filters=None, *, page=1, page_size=20):
+        filters = dict(filters or {})
+        if filters.get("ontology_uid"):
+            filters["ontology_uid"] = _uuid(
+                filters["ontology_uid"],
+                "ontology_uid",
+            )
+        if filters.get("code_type") not in (None, "", *CODE_TYPES):
+            raise DeviceSemanticInvalid("code_type is not supported")
+        if filters.get("status") not in (None, "", *CODE_STATUSES):
+            raise DeviceSemanticInvalid("status is not supported")
+        keyword = str(filters.get("keyword") or "").strip()
+        if len(keyword) > 300:
+            raise DeviceSemanticInvalid(
+                "keyword exceeds 300 characters"
+            )
+        filters["keyword"] = keyword
+        try:
+            page_value = int(page)
+            page_size_value = int(page_size)
+        except (TypeError, ValueError) as exc:
+            raise DeviceSemanticInvalid(
+                "page and page_size must be integers"
+            ) from exc
+        if page_value <= 0 or not 1 <= page_size_value <= 100:
+            raise DeviceSemanticInvalid(
+                "page must be positive and page_size must be 1..100"
+            )
+        return self.repository.search(
+            filters,
+            page=page_value,
+            page_size=page_size_value,
+        )
+
+    def get(self, code_uid):
+        return self._get(code_uid)
+
+    def versions(self, code_uid):
+        record = self._get(code_uid)
+        return self.repository.list_versions(record.uid)
+
+    def reviews(self, code_uid):
+        record = self._get(code_uid)
+        return self.repository.list_reviews(record.uid)
+
+
+def _class(uid: str, name: str, description: str):
+    return {
+        "uid": uid,
+        "name": name,
+        "description": description,
+    }
+
+
+def _property(
+    uid: str,
+    name: str,
+    class_uid: str,
+    *,
+    required=False,
+    cardinality="0..1",
+):
+    return {
+        "uid": uid,
+        "name": name,
+        "class_uid": class_uid,
+        "cardinality": cardinality,
+        "required": bool(required),
+    }
+
+
+def _relation(uid: str, name: str, from_uid: str, to_uid: str):
+    return {
+        "uid": uid,
+        "name": name,
+        "from_class_uid": from_uid,
+        "to_class_uid": to_uid,
+    }
+
+
+def build_device_graph(owner_domain_uid: str) -> dict[str, list[dict]]:
+    domain_uid = _uuid(owner_domain_uid, "owner_domain_uid")
+    classes = [
+        _class("device", "设备", "具有稳定平台标识的可治理设备主对象"),
+        _class("component", "部件", "组成设备并可独立维护的部件"),
+        _class("measurement_point", "测点", "观测设备状态的测量位置或信号"),
+        _class("alarm", "告警", "设备或测点产生的异常事件"),
+        _class("maintenance_record", "维护记录", "设备检查、维修与保养活动"),
+        _class("location", "位置", "设备安装或运行的空间位置"),
+        _class("organization", "组织", "设备所属或运维责任组织"),
+        _class("person", "责任人", "承担设备治理或运维责任的人员"),
+        _class("fault", "故障", "经治理确认的故障分类"),
+        _class("cause", "原因", "故障形成的原因分类"),
+        _class("action", "措施", "消除故障或降低影响的处置措施"),
+    ]
+    properties = [
+        _property(
+            "device.platform_uid",
+            "平台设备UID",
+            "device",
+            required=True,
+            cardinality="1",
+        ),
+        _property(
+            "device.source_code",
+            "源系统设备编码",
+            "device",
+            required=True,
+            cardinality="1..*",
+        ),
+        _property(
+            "device.name",
+            "设备名称",
+            "device",
+            required=True,
+            cardinality="1",
+        ),
+        _property("device.status", "设备状态", "device"),
+        _property("device.location", "设备位置", "device"),
+        _property("component.platform_uid", "平台部件UID", "component"),
+        _property(
+            "measurement_point.platform_uid",
+            "平台测点UID",
+            "measurement_point",
+        ),
+        _property("alarm.platform_uid", "平台告警UID", "alarm"),
+        _property(
+            "maintenance_record.platform_uid",
+            "平台维护记录UID",
+            "maintenance_record",
+        ),
+        _property("fault.code", "故障代码", "fault", required=True),
+        _property("cause.code", "原因代码", "cause", required=True),
+        _property("action.code", "措施代码", "action", required=True),
+    ]
+    relations = [
+        _relation("component.part_of", "部件属于设备", "component", "device"),
+        _relation(
+            "measurement_point.monitors",
+            "测点监测设备",
+            "measurement_point",
+            "device",
+        ),
+        _relation("alarm.raised_by", "告警由设备产生", "alarm", "device"),
+        _relation(
+            "maintenance_record.maintains",
+            "维护记录作用于设备",
+            "maintenance_record",
+            "device",
+        ),
+        _relation("device.located_at", "设备位于", "device", "location"),
+        _relation("device.owned_by", "设备归属组织", "device", "organization"),
+        _relation("device.responsible_to", "设备责任人", "device", "person"),
+        _relation("alarm.classified_as", "告警归类为故障", "alarm", "fault"),
+        _relation("fault.caused_by", "故障由原因导致", "fault", "cause"),
+        _relation(
+            "maintenance_record.uses_action",
+            "维护记录采用措施",
+            "maintenance_record",
+            "action",
+        ),
+    ]
+    mappings = [
+        {
+            "property_uid": "device.platform_uid",
+            "data_element_uid": "platform:device_assets.uid",
+            "source": "device_assets.uid",
+        },
+        {
+            "property_uid": "device.source_code",
+            "data_element_uid": "platform:device_asset_source_mappings.source_code",
+            "source": "device_asset_source_mappings.source_code",
+        },
+        {
+            "property_uid": "device.name",
+            "data_element_uid": "platform:device_assets.name",
+            "source": "device_assets.name",
+        },
+        {
+            "property_uid": "fault.code",
+            "data_element_uid": "platform:device_semantic_codes.fault",
+            "source": "device_semantic_codes.canonical_code",
+        },
+        {
+            "property_uid": "cause.code",
+            "data_element_uid": "platform:device_semantic_codes.cause",
+            "source": "device_semantic_codes.canonical_code",
+        },
+        {
+            "property_uid": "action.code",
+            "data_element_uid": "platform:device_semantic_codes.action",
+            "source": "device_semantic_codes.canonical_code",
+        },
+    ]
+    return {
+        "classes": classes,
+        "properties": properties,
+        "relations": relations,
+        "constraints": [
+            {
+                "uid": "device.platform_uid.unique",
+                "name": "平台设备UID唯一",
+                "expression": "unique(device.platform_uid)",
+            },
+            {
+                "uid": "device.source_identity.unique",
+                "name": "来源设备身份唯一",
+                "expression": (
+                    "unique(source_uid,source_entity,asset_type,source_code)"
+                ),
+            },
+        ],
+        "domain_links": [
+            {"domain_uid": domain_uid, "role": "owner"},
+        ],
+        "element_mappings": mappings,
+    }
+
+
+def assess_device_graph(payload: Any) -> DeviceSemanticProfile:
+    graph = dict(payload or {})
+    classes = {
+        str(item.get("uid"))
+        for item in graph.get("classes") or ()
+        if isinstance(item, dict)
+    }
+    relations = {
+        str(item.get("uid"))
+        for item in graph.get("relations") or ()
+        if isinstance(item, dict)
+    }
+    mappings = {
+        str(item.get("property_uid"))
+        for item in graph.get("element_mappings") or ()
+        if isinstance(item, dict)
+    }
+    missing_classes = tuple(
+        uid for uid in REQUIRED_CLASS_UIDS if uid not in classes
+    )
+    missing_relations = tuple(
+        uid for uid in REQUIRED_RELATION_UIDS if uid not in relations
+    )
+    missing_mappings = tuple(
+        uid for uid in REQUIRED_MAPPING_UIDS if uid not in mappings
+    )
+    return DeviceSemanticProfile(
+        ready_to_publish=not (
+            missing_classes or missing_relations or missing_mappings
+        ),
+        class_count=len(classes),
+        relation_count=len(relations),
+        mapping_count=len(mappings),
+        missing_classes=missing_classes,
+        missing_relations=missing_relations,
+        missing_mappings=missing_mappings,
+    )
+
+
+class DeviceSemanticService:
+    def __init__(
+        self,
+        ontology_repository,
+        *,
+        commit: Callable[[], Any] = lambda: None,
+        rollback: Callable[[], Any] = lambda: None,
+    ):
+        self.ontology_repository = ontology_repository
+        self.commit = commit
+        self.rollback = rollback
+
+    def bootstrap(self, payload, actor_uid) -> DeviceSemanticBootstrapResult:
+        if not isinstance(payload, dict):
+            raise DeviceSemanticInvalid("payload must be an object")
+        existing = self.ontology_repository.find_by_code(
+            DEVICE_ONTOLOGY_CODE
+        )
+        if existing is not None:
+            version = self.ontology_repository.latest_version(existing.uid)
+            if version is None:
+                raise DeviceSemanticInvalid(
+                    "device ontology exists without a draft"
+                )
+            return DeviceSemanticBootstrapResult(
+                ontology=existing,
+                version=version,
+                profile=assess_device_graph(
+                    version.graph_document.to_dict()
+                ),
+                created=False,
+            )
+        domain_uid = _uuid(
+            payload.get("owner_domain_uid"),
+            "owner_domain_uid",
+        )
+        try:
+            ontology = self.ontology_repository.create(
+                code=DEVICE_ONTOLOGY_CODE,
+                name="设备语义本体",
+                owner_uid=str(payload.get("owner_uid") or actor_uid),
+                domain_links=[
+                    {"domain_uid": domain_uid, "role": "owner"}
+                ],
+                created_by=actor_uid,
+            )
+            version = self.ontology_repository.save_draft(
+                ontology.uid,
+                build_device_graph(domain_uid),
+                expected_revision=0,
+                actor_uid=actor_uid,
+            )
+            self.commit()
+            return DeviceSemanticBootstrapResult(
+                ontology=self.ontology_repository.get(ontology.uid),
+                version=version,
+                profile=assess_device_graph(
+                    version.graph_document.to_dict()
+                ),
+                created=True,
+            )
+        except Exception:
+            self.rollback()
+            raise
+
+    def profile(self) -> DeviceSemanticBootstrapResult | None:
+        ontology = self.ontology_repository.find_by_code(
+            DEVICE_ONTOLOGY_CODE
+        )
+        if ontology is None:
+            return None
+        version = self.ontology_repository.latest_version(ontology.uid)
+        if version is None:
+            raise DeviceSemanticInvalid(
+                "device ontology exists without a draft"
+            )
+        return DeviceSemanticBootstrapResult(
+            ontology=ontology,
+            version=version,
+            profile=assess_device_graph(version.graph_document.to_dict()),
+            created=False,
+        )
+
+
+class DeviceOntologyPublicationAuthorizer:
+    def __init__(
+        self,
+        ontology_repository,
+        *,
+        responsibility_lookup: Callable[[str, str], dict],
+    ):
+        self.ontology_repository = ontology_repository
+        self.responsibility_lookup = responsibility_lookup
+
+    def __call__(self, version, actor_uid):
+        ontology = self.ontology_repository.get(version.ontology_uid)
+        if ontology is None:
+            raise DeviceSemanticInvalid("ontology was not found")
+        if ontology.code != DEVICE_ONTOLOGY_CODE:
+            return
+        if not assess_device_graph(
+            version.graph_document.to_dict()
+        ).ready_to_publish:
+            raise DeviceSemanticInvalid(
+                "device semantic profile is incomplete"
+            )
+        self.assert_accountable(ontology.uid, actor_uid)
+
+    def assert_accountable(self, ontology_uid, actor_uid):
+        matrix = self.responsibility_lookup(
+            "device_ontology",
+            str(ontology_uid),
+        )
+        accountable = {
+            str(item.get("user_id"))
+            for item in matrix.get("assignments") or ()
+            if item.get("responsibility_role") == "asset_manager"
+            and item.get("raci_role") == "accountable"
+        }
+        if str(actor_uid) not in accountable:
+            raise DeviceSemanticForbidden(
+                "accountable device asset manager approval is required"
+            )
+
+
+__all__ = [
+    "CODE_STATUSES",
+    "CODE_TYPES",
+    "DEVICE_ONTOLOGY_CODE",
+    "DeviceOntologyPublicationAuthorizer",
+    "DeviceSemanticBootstrapResult",
+    "DeviceSemanticCodeRecord",
+    "DeviceSemanticCodeReviewRecord",
+    "DeviceSemanticCodeService",
+    "DeviceSemanticCodeVersionRecord",
+    "DeviceSemanticForbidden",
+    "DeviceSemanticInvalid",
+    "DeviceSemanticProfile",
+    "DeviceSemanticService",
+    "assess_device_graph",
+    "build_device_graph",
+]

+ 160 - 0
app/core/data_research/errors.py

@@ -0,0 +1,160 @@
+class DataResearchError(ValueError):
+    code = "DATA_RESEARCH_ERROR"
+    http_status = 400
+
+
+class IngestionPayloadInvalid(DataResearchError):
+    code = "INGESTION_PAYLOAD_INVALID"
+
+
+class InvalidJobTransition(DataResearchError):
+    code = "INGESTION_TRANSITION_INVALID"
+    http_status = 409
+
+
+class IngestionJobNotFound(DataResearchError):
+    code = "INGESTION_JOB_NOT_FOUND"
+    http_status = 404
+
+
+class IngestionSourceInvalid(DataResearchError):
+    code = "INGESTION_SOURCE_INVALID"
+    http_status = 422
+
+
+class DataElementInvalid(DataResearchError):
+    code = "DATA_ELEMENT_INVALID"
+
+
+class DataElementNotFound(DataResearchError):
+    code = "DATA_ELEMENT_NOT_FOUND"
+    http_status = 404
+
+
+class DataElementVersionConflict(DataResearchError):
+    code = "DATA_ELEMENT_VERSION_CONFLICT"
+    http_status = 409
+
+
+class DataElementTransitionInvalid(DataResearchError):
+    code = "DATA_ELEMENT_TRANSITION_INVALID"
+    http_status = 409
+
+
+class CandidateDecisionInvalid(DataResearchError):
+    code = "CANDIDATE_DECISION_INVALID"
+
+
+class DataElementProjectionInvalid(DataResearchError):
+    code = "DATA_ELEMENT_PROJECTION_INVALID"
+    http_status = 422
+
+
+class DeviceAssetInvalid(DataResearchError):
+    code = "DEVICE_ASSET_INVALID"
+    http_status = 422
+
+
+class DeviceAssetNotFound(DataResearchError):
+    code = "DEVICE_ASSET_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceSemanticInvalid(DataResearchError):
+    code = "DEVICE_SEMANTIC_INVALID"
+    http_status = 422
+
+
+class DeviceSemanticForbidden(DataResearchError):
+    code = "DEVICE_SEMANTIC_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceSemanticNotFound(DataResearchError):
+    code = "DEVICE_SEMANTIC_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceSemanticConflict(DataResearchError):
+    code = "DEVICE_SEMANTIC_CONFLICT"
+    http_status = 409
+
+
+class DeviceEntityInvalid(DataResearchError):
+    code = "DEVICE_ENTITY_INVALID"
+    http_status = 422
+
+
+class DeviceEntityForbidden(DataResearchError):
+    code = "DEVICE_ENTITY_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceEntityNotFound(DataResearchError):
+    code = "DEVICE_ENTITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceEntityConflict(DataResearchError):
+    code = "DEVICE_ENTITY_CONFLICT"
+    http_status = 409
+
+
+class DeviceQualityInvalid(DataResearchError):
+    code = "DEVICE_QUALITY_INVALID"
+    http_status = 422
+
+
+class DeviceQualityForbidden(DataResearchError):
+    code = "DEVICE_QUALITY_FORBIDDEN"
+    http_status = 403
+
+
+class DeviceQualityNotFound(DataResearchError):
+    code = "DEVICE_QUALITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceQualityConflict(DataResearchError):
+    code = "DEVICE_QUALITY_CONFLICT"
+    http_status = 409
+
+
+class QualityIssueInvalid(DataResearchError):
+    code = "QUALITY_ISSUE_INVALID"
+    http_status = 422
+
+
+class QualityIssueForbidden(DataResearchError):
+    code = "QUALITY_ISSUE_FORBIDDEN"
+    http_status = 403
+
+
+class QualityIssueNotFound(DataResearchError):
+    code = "QUALITY_ISSUE_NOT_FOUND"
+    http_status = 404
+
+
+class QualityIssueConflict(DataResearchError):
+    code = "QUALITY_ISSUE_CONFLICT"
+    http_status = 409
+
+
+class DeviceObservabilityInvalid(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_INVALID"
+    http_status = 422
+
+
+class DeviceObservabilityNotFound(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_NOT_FOUND"
+    http_status = 404
+
+
+class DeviceObservabilityConflict(DataResearchError):
+    code = "DEVICE_OBSERVABILITY_CONFLICT"
+    http_status = 409
+
+
+class GovernanceMetricInvalid(DataResearchError):
+    code = "GOVERNANCE_METRIC_INVALID"
+    http_status = 400

+ 3 - 0
app/core/data_research/extractors/__init__.py

@@ -0,0 +1,3 @@
+from app.core.data_research.extractors.registry import default_registry
+
+__all__ = ["default_registry"]

+ 62 - 0
app/core/data_research/extractors/base.py

@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import hashlib
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass(frozen=True)
+class ExtractionContext:
+    filename: str
+    media_type: str
+    max_bytes: int = 25 * 1024 * 1024
+
+
+@dataclass(frozen=True)
+class ExtractionEvidence:
+    locator: dict[str, Any]
+    excerpt: str | None = None
+    confidence: float | None = None
+
+
+@dataclass(frozen=True)
+class ExtractedItem:
+    candidate_type: str
+    data: dict[str, Any]
+    evidence: tuple[ExtractionEvidence, ...] = ()
+    confidence: float = 1.0
+
+
+@dataclass(frozen=True)
+class ExtractionBatch:
+    parser_version: str
+    content_hash: str
+    items: tuple[ExtractedItem, ...]
+    warnings: tuple[str, ...] = ()
+    metadata: dict[str, Any] = field(default_factory=dict)
+
+
+class BaseExtractor:
+    parser_version = ""
+
+    def can_handle(self, media_type: str, filename: str) -> bool:
+        raise NotImplementedError
+
+    def extract(self, content: bytes, context: ExtractionContext) -> ExtractionBatch:
+        raise NotImplementedError
+
+    def _batch(
+        self,
+        content: bytes,
+        items: list[ExtractedItem],
+        *,
+        warnings: list[str] | None = None,
+        metadata: dict[str, Any] | None = None,
+    ) -> ExtractionBatch:
+        return ExtractionBatch(
+            parser_version=self.parser_version,
+            content_hash=hashlib.sha256(content).hexdigest(),
+            items=tuple(items),
+            warnings=tuple(warnings or ()),
+            metadata=dict(metadata or {}),
+        )

+ 96 - 0
app/core/data_research/extractors/csv.py

@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import csv
+import io
+import re
+from pathlib import Path
+
+from app.core.data_research.extractors.base import (
+    BaseExtractor,
+    ExtractedItem,
+    ExtractionContext,
+    ExtractionEvidence,
+)
+
+
+_INTEGER = re.compile(r"^[+-]?\d+$")
+_DECIMAL = re.compile(r"^[+-]?(?:\d+\.\d*|\d*\.\d+)$")
+_BOOLEAN = frozenset({"true", "false", "yes", "no"})
+
+
+def infer_type(values) -> str:
+    present = [str(value).strip() for value in values if str(value).strip()]
+    if not present:
+        return "string"
+    lowered = [value.lower() for value in present]
+    if all(value in _BOOLEAN for value in lowered):
+        return "boolean"
+    if all(_INTEGER.fullmatch(value) for value in present):
+        return "integer"
+    if all(_INTEGER.fullmatch(value) or _DECIMAL.fullmatch(value) for value in present):
+        return "decimal"
+    return "string"
+
+
+class CsvExtractor(BaseExtractor):
+    parser_version = "csv-v1"
+
+    def can_handle(self, media_type: str, filename: str) -> bool:
+        return filename.lower().endswith(".csv") or media_type in {
+            "text/csv",
+            "application/csv",
+        }
+
+    def extract(self, content: bytes, context: ExtractionContext):
+        try:
+            text = content.decode("utf-8-sig")
+        except UnicodeDecodeError as exc:
+            raise ValueError("CSV file must be UTF-8 encoded") from exc
+        if not text.strip():
+            raise ValueError("CSV header is required")
+        try:
+            dialect = csv.Sniffer().sniff(text[:8192], delimiters=",;\t|")
+        except csv.Error:
+            dialect = csv.excel
+        rows = list(csv.reader(io.StringIO(text), dialect))
+        if not rows or not any(str(value).strip() for value in rows[0]):
+            raise ValueError("CSV header is required")
+        headers = [str(value).strip() for value in rows[0]]
+        data_rows = rows[1:]
+        columns = []
+        for index, name in enumerate(headers, start=1):
+            values = [row[index - 1] if len(row) >= index else "" for row in data_rows]
+            columns.append(
+                {
+                    "name_zh": "",
+                    "name_en": name,
+                    "data_type": infer_type(values),
+                    "nullable": any(not str(value).strip() for value in values),
+                    "evidence_locator": {
+                        "kind": "csv.header",
+                        "column": index,
+                        "name": name,
+                    },
+                }
+            )
+        item = ExtractedItem(
+            candidate_type="physical_asset",
+            data={
+                "table_info": {
+                    "name_zh": "",
+                    "name_en": Path(context.filename).stem,
+                },
+                "columns": columns,
+            },
+            evidence=(
+                ExtractionEvidence(
+                    locator={"kind": "csv.header", "row": 1},
+                    excerpt=",".join(headers),
+                ),
+            ),
+        )
+        return self._batch(
+            content,
+            [item],
+            metadata={"row_count": len(data_rows), "delimiter": dialect.delimiter},
+        )

+ 67 - 0
app/core/data_research/extractors/docx.py

@@ -0,0 +1,67 @@
+from __future__ import annotations
+
+import io
+
+from docx import Document
+
+from app.core.data_research.extractors.base import (
+    BaseExtractor,
+    ExtractedItem,
+    ExtractionContext,
+    ExtractionEvidence,
+)
+
+
+class DocxExtractor(BaseExtractor):
+    parser_version = "docx-v1"
+
+    def can_handle(self, media_type: str, filename: str) -> bool:
+        return filename.lower().endswith(".docx") or media_type == (
+            "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+        )
+
+    def extract(self, content: bytes, context: ExtractionContext):
+        document = Document(io.BytesIO(content))
+        items = []
+        for index, paragraph in enumerate(document.paragraphs, start=1):
+            value = paragraph.text.strip()
+            if not value:
+                continue
+            evidence = ExtractionEvidence(
+                locator={"kind": "docx.paragraph", "paragraph": index},
+                excerpt=value,
+            )
+            items.append(
+                ExtractedItem(
+                    candidate_type="document_fragment",
+                    data={"kind": "paragraph", "text": value},
+                    evidence=(evidence,),
+                )
+            )
+        for table_index, table in enumerate(document.tables, start=1):
+            header = tuple(cell.text.strip() for cell in table.rows[0].cells) if table.rows else ()
+            for row_index, row in enumerate(table.rows, start=1):
+                values = tuple(cell.text.strip() for cell in row.cells)
+                if row_index > 1 and values == header:
+                    continue
+                for cell_index, value in enumerate(values, start=1):
+                    if not value:
+                        continue
+                    evidence = ExtractionEvidence(
+                        locator={
+                            "kind": "docx.table",
+                            "table": table_index,
+                            "row": row_index,
+                            "cell": cell_index,
+                        },
+                        excerpt=value,
+                    )
+                    items.append(
+                        ExtractedItem(
+                            candidate_type="document_fragment",
+                            data={"kind": "table_cell", "text": value},
+                            evidence=(evidence,),
+                        )
+                    )
+        warnings = [] if items else ["document contains no usable text"]
+        return self._batch(content, items, warnings=warnings)

+ 78 - 0
app/core/data_research/extractors/excel.py

@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+import io
+
+import pandas as pd
+
+from app.core.data_research.extractors.base import (
+    BaseExtractor,
+    ExtractedItem,
+    ExtractionContext,
+    ExtractionEvidence,
+)
+
+
+def _column_letter(index: int) -> str:
+    result = ""
+    while index:
+        index, remainder = divmod(index - 1, 26)
+        result = chr(65 + remainder) + result
+    return result
+
+
+def _pandas_type(series) -> str:
+    if pd.api.types.is_bool_dtype(series.dtype):
+        return "boolean"
+    if pd.api.types.is_integer_dtype(series.dtype):
+        return "integer"
+    if pd.api.types.is_float_dtype(series.dtype):
+        return "decimal"
+    if pd.api.types.is_datetime64_any_dtype(series.dtype):
+        return "datetime"
+    return "string"
+
+
+class ExcelExtractor(BaseExtractor):
+    parser_version = "excel-v1"
+
+    def can_handle(self, media_type: str, filename: str) -> bool:
+        lowered = filename.lower()
+        return lowered.endswith((".xlsx", ".xls")) or "spreadsheet" in media_type or "excel" in media_type
+
+    def extract(self, content: bytes, context: ExtractionContext):
+        workbook = pd.ExcelFile(io.BytesIO(content))
+        items = []
+        for sheet_name in workbook.sheet_names:
+            frame = pd.read_excel(workbook, sheet_name=sheet_name)
+            columns = []
+            last_row = max(1, len(frame) + 1)
+            for index, name in enumerate(frame.columns, start=1):
+                letter = _column_letter(index)
+                columns.append(
+                    {
+                        "name_zh": "",
+                        "name_en": str(name),
+                        "data_type": _pandas_type(frame[name]),
+                        "nullable": bool(frame[name].isna().any()),
+                        "evidence_locator": {
+                            "kind": "excel.cell_range",
+                            "sheet": sheet_name,
+                            "range": f"{letter}1:{letter}{last_row}",
+                        },
+                    }
+                )
+            items.append(
+                ExtractedItem(
+                    candidate_type="physical_asset",
+                    data={
+                        "table_info": {"name_zh": "", "name_en": sheet_name},
+                        "columns": columns,
+                    },
+                    evidence=(
+                        ExtractionEvidence(
+                            locator={"kind": "excel.sheet", "sheet": sheet_name},
+                        ),
+                    ),
+                )
+            )
+        return self._batch(content, items, metadata={"sheet_count": len(items)})

+ 47 - 0
app/core/data_research/extractors/image.py

@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from app.core.data_research.extractors.base import (
+    BaseExtractor,
+    ExtractedItem,
+    ExtractionContext,
+    ExtractionEvidence,
+)
+
+
+class ImageExtractor(BaseExtractor):
+    parser_version = "image-ocr-v1"
+
+    def __init__(self, ocr_service):
+        self.ocr_service = ocr_service
+
+    def can_handle(self, media_type: str, filename: str) -> bool:
+        suffix = filename.lower()
+        return media_type in {"image/png", "image/jpeg"} or suffix.endswith(
+            (".png", ".jpg", ".jpeg")
+        )
+
+    def extract(self, content: bytes, context: ExtractionContext):
+        items = []
+        for result in self.ocr_service.extract_pages([content]):
+            evidence = ExtractionEvidence(
+                locator={
+                    "kind": "image.ocr",
+                    "page": result.page,
+                    "bbox": list(result.bbox),
+                },
+                excerpt=result.text,
+                confidence=result.confidence,
+            )
+            items.append(
+                ExtractedItem(
+                    candidate_type="document_fragment",
+                    data={
+                        "kind": "ocr_block",
+                        "text": result.text,
+                        "review_required": result.review_required,
+                    },
+                    evidence=(evidence,),
+                    confidence=result.confidence,
+                )
+            )
+        return self._batch(content, items)

+ 84 - 0
app/core/data_research/extractors/pdf.py

@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+import io
+
+import pdfplumber
+
+from app.core.data_research.extractors.base import (
+    BaseExtractor,
+    ExtractedItem,
+    ExtractionContext,
+    ExtractionEvidence,
+)
+
+
+class PdfExtractor(BaseExtractor):
+    parser_version = "pdf-text-v1"
+
+    def can_handle(self, media_type: str, filename: str) -> bool:
+        return filename.lower().endswith(".pdf") or media_type == "application/pdf"
+
+    @staticmethod
+    def _page_evidence(page_number, text, bbox):
+        x0, top, x1, bottom = [float(value) for value in bbox]
+        width = max(1.0, x1 - x0)
+        height = max(1.0, bottom - top)
+        return ExtractionEvidence(
+            locator={
+                "kind": "pdf.page",
+                "page": page_number,
+                "bbox": [0.0, 0.0, round(width / width, 6), round(height / height, 6)],
+            },
+            excerpt=text,
+        )
+
+    def extract(self, content: bytes, context: ExtractionContext):
+        items = []
+        ocr_required = []
+        with pdfplumber.open(io.BytesIO(content)) as document:
+            for page_number, page in enumerate(document.pages, start=1):
+                text = str(page.extract_text() or "").strip()
+                if not text:
+                    ocr_required.append(page_number)
+                    continue
+                items.append(
+                    ExtractedItem(
+                        candidate_type="document_fragment",
+                        data={"kind": "page", "text": text},
+                        evidence=(self._page_evidence(page_number, text, page.bbox),),
+                    )
+                )
+                for table_index, table in enumerate(page.extract_tables() or (), start=1):
+                    for row_index, row in enumerate(table or (), start=1):
+                        for cell_index, cell in enumerate(row or (), start=1):
+                            value = str(cell or "").strip()
+                            if not value:
+                                continue
+                            items.append(
+                                ExtractedItem(
+                                    candidate_type="document_fragment",
+                                    data={"kind": "table_cell", "text": value},
+                                    evidence=(
+                                        ExtractionEvidence(
+                                            locator={
+                                                "kind": "pdf.table",
+                                                "page": page_number,
+                                                "table": table_index,
+                                                "row": row_index,
+                                                "cell": cell_index,
+                                            },
+                                            excerpt=value,
+                                        ),
+                                    ),
+                                )
+                            )
+        warnings = []
+        if ocr_required:
+            pages = ", ".join(str(value) for value in ocr_required)
+            warnings.append(f"OCR required for pages: {pages}")
+        return self._batch(
+            content,
+            items,
+            warnings=warnings,
+            metadata={"ocr_required_pages": ocr_required},
+        )

+ 33 - 0
app/core/data_research/extractors/registry.py

@@ -0,0 +1,33 @@
+from __future__ import annotations
+
+from app.core.data_research.extractors.base import ExtractionContext
+from app.core.data_research.extractors.csv import CsvExtractor
+from app.core.data_research.extractors.excel import ExcelExtractor
+from app.core.data_research.extractors.docx import DocxExtractor
+from app.core.data_research.extractors.pdf import PdfExtractor
+from app.core.data_research.extractors.sql import SqlExtractor
+
+
+class ExtractorRegistry:
+    def __init__(self, extractors):
+        self.extractors = tuple(extractors)
+
+    def resolve(self, media_type: str, filename: str):
+        for extractor in self.extractors:
+            if extractor.can_handle(media_type, filename):
+                return extractor
+        raise ValueError(f"unsupported source format: {filename}")
+
+    def extract(self, content: bytes, context: ExtractionContext):
+        if len(content) > int(context.max_bytes):
+            raise ValueError("source exceeds configured size limit")
+        return self.resolve(context.media_type, context.filename).extract(
+            content,
+            context,
+        )
+
+
+def default_registry():
+    return ExtractorRegistry(
+        (SqlExtractor(), CsvExtractor(), ExcelExtractor(), DocxExtractor(), PdfExtractor())
+    )

+ 41 - 0
app/core/data_research/extractors/sql.py

@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from app.core.data_research.extractors.base import (
+    BaseExtractor,
+    ExtractedItem,
+    ExtractionContext,
+    ExtractionEvidence,
+)
+from app.core.llm.ddl_parser import DDLParser
+
+
+class SqlExtractor(BaseExtractor):
+    parser_version = "sql-ddl-v1"
+
+    def can_handle(self, media_type: str, filename: str) -> bool:
+        return filename.lower().endswith(".sql") or media_type in {
+            "application/sql",
+            "text/sql",
+        }
+
+    def extract(self, content: bytes, context: ExtractionContext):
+        try:
+            sql_text = content.decode("utf-8-sig")
+        except UnicodeDecodeError as exc:
+            raise ValueError("SQL file must be UTF-8 encoded") from exc
+        tables = DDLParser()._parse_sql_ddl_fallback(sql_text)
+        if not tables:
+            raise ValueError("no supported CREATE TABLE or CREATE VIEW statements found")
+        items = [
+            ExtractedItem(
+                candidate_type="physical_asset",
+                data=table,
+                evidence=(
+                    ExtractionEvidence(
+                        locator={"kind": "sql.statement", "statement": index},
+                    ),
+                ),
+            )
+            for index, table in enumerate(tables, start=1)
+        ]
+        return self._batch(content, items)

+ 73 - 0
app/core/data_research/file_policy.py

@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+
+class FilePolicyViolation(ValueError):
+    pass
+
+
+@dataclass(frozen=True)
+class ValidatedFile:
+    filename: str
+    extension: str
+    media_type: str
+    size_bytes: int
+
+
+class FilePolicy:
+    MEDIA_TYPES = {
+        ".sql": {"text/plain", "application/sql", "text/sql"},
+        ".csv": {"text/csv", "application/csv", "text/plain"},
+        ".xls": {"application/vnd.ms-excel"},
+        ".xlsx": {"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"},
+        ".docx": {"application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
+        ".pdf": {"application/pdf"},
+        ".png": {"image/png"},
+        ".jpg": {"image/jpeg"},
+        ".jpeg": {"image/jpeg"},
+    }
+
+    def __init__(self, *, max_bytes=25 * 1024 * 1024, max_pages=500):
+        self.max_bytes = int(max_bytes)
+        self.max_pages = int(max_pages)
+
+    @staticmethod
+    def _magic_matches(extension, content):
+        signatures = {
+            ".pdf": (b"%PDF-",),
+            ".png": (b"\x89PNG\r\n\x1a\n",),
+            ".jpg": (b"\xff\xd8\xff",),
+            ".jpeg": (b"\xff\xd8\xff",),
+            ".docx": (b"PK\x03\x04",),
+            ".xlsx": (b"PK\x03\x04",),
+            ".xls": (b"\xd0\xcf\x11\xe0",),
+        }
+        expected = signatures.get(extension)
+        if expected is None:
+            try:
+                content[:4096].decode("utf-8-sig")
+                return True
+            except UnicodeDecodeError:
+                return False
+        return any(content.startswith(signature) for signature in expected)
+
+    def validate(self, filename, media_type, content, *, page_count=None):
+        filename = str(filename or "")
+        if not filename or Path(filename).name != filename or "\x00" in filename:
+            raise FilePolicyViolation("unsafe filename")
+        extension = Path(filename).suffix.lower()
+        if extension == ".doc":
+            raise FilePolicyViolation("legacy DOC must be converted to DOCX")
+        if extension not in self.MEDIA_TYPES:
+            raise FilePolicyViolation("file extension is not allowed")
+        if str(media_type or "").lower() not in self.MEDIA_TYPES[extension]:
+            raise FilePolicyViolation("media type does not match file extension")
+        if len(content) > self.max_bytes:
+            raise FilePolicyViolation("file exceeds configured size limit")
+        if page_count is not None and int(page_count) > self.max_pages:
+            raise FilePolicyViolation("document exceeds configured page limit")
+        if not self._magic_matches(extension, content):
+            raise FilePolicyViolation("file signature does not match extension")
+        return ValidatedFile(filename, extension, str(media_type).lower(), len(content))

+ 333 - 0
app/core/data_research/governance_metric_repository.py

@@ -0,0 +1,333 @@
+"""Authorization-aware PostgreSQL queries for governance operational metrics."""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import text
+
+_SCOPE_CTES = """
+WITH authorized_sources AS (
+    SELECT s.uid
+    FROM public.ingestion_sources s
+    WHERE s.status = 'active'
+      AND (
+        :global_access
+        OR EXISTS (
+            SELECT 1
+            FROM jsonb_array_elements_text(
+                COALESCE(
+                    s.permission_scope -> 'business_domains',
+                    '[]'::jsonb
+                )
+            ) AS domain(uid)
+            WHERE domain.uid = ANY(
+                CAST(:business_domain_uids AS text[])
+            )
+        )
+      )
+),
+visible_assets AS (
+    SELECT a.*
+    FROM public.device_assets a
+    WHERE a.status = 'active'
+      AND (
+        :global_access
+        OR EXISTS (
+            SELECT 1
+            FROM public.device_asset_source_mappings mapping
+            JOIN authorized_sources source
+              ON source.uid = mapping.source_uid
+            WHERE mapping.asset_uid = a.uid
+        )
+      )
+),
+visible_mappings AS (
+    SELECT mapping.*
+    FROM public.device_asset_source_mappings mapping
+    JOIN authorized_sources source ON source.uid = mapping.source_uid
+    JOIN visible_assets asset ON asset.uid = mapping.asset_uid
+),
+active_merges AS (
+    SELECT merge.*
+    FROM public.device_entity_merge_events merge
+    JOIN visible_assets canonical
+      ON canonical.uid = merge.canonical_asset_uid
+    JOIN visible_assets member
+      ON member.uid = merge.member_asset_uid
+    LEFT JOIN public.device_entity_merge_rollbacks rollback
+      ON rollback.merge_uid = merge.uid
+    WHERE rollback.uid IS NULL
+),
+mapped_assets AS (
+    SELECT canonical_asset_uid AS asset_uid, uid AS merge_uid
+    FROM active_merges
+    UNION ALL
+    SELECT member_asset_uid AS asset_uid, uid AS merge_uid
+    FROM active_merges
+),
+visible_issues AS (
+    SELECT issue.*
+    FROM public.device_quality_issues issue
+    JOIN visible_assets asset ON asset.uid = issue.asset_uid
+    WHERE
+      :global_access
+      OR CASE
+        WHEN issue.source_uid IS NOT NULL THEN EXISTS (
+            SELECT 1
+            FROM authorized_sources source
+            WHERE source.uid = issue.source_uid
+        )
+        WHEN issue.source_mapping_uid IS NOT NULL THEN EXISTS (
+            SELECT 1
+            FROM visible_mappings mapping
+            WHERE mapping.uid = issue.source_mapping_uid
+        )
+        ELSE TRUE
+      END
+)
+"""
+
+_ASSET_DETAIL_SELECT = """
+SELECT
+    asset.uid::text AS asset_uid,
+    asset.name AS asset_name,
+    asset.asset_type,
+    asset.location,
+    asset.organization,
+    asset.responsible_person,
+    COALESCE(
+        (
+            SELECT array_agg(DISTINCT mapping.source_code ORDER BY mapping.source_code)
+            FROM visible_mappings mapping
+            WHERE mapping.asset_uid = asset.uid
+        ),
+        ARRAY[]::varchar[]
+    ) AS source_codes,
+    ARRAY_REMOVE(
+        ARRAY[
+            CASE WHEN NULLIF(BTRIM(asset.name), '') IS NULL
+                THEN 'name' END,
+            CASE WHEN NULLIF(BTRIM(asset.location), '') IS NULL
+                THEN 'location' END,
+            CASE WHEN NULLIF(BTRIM(asset.organization), '') IS NULL
+                THEN 'organization' END,
+            CASE WHEN NULLIF(BTRIM(asset.responsible_person), '') IS NULL
+                THEN 'responsible_person' END,
+            CASE WHEN NOT EXISTS (
+                SELECT 1
+                FROM visible_mappings mapping
+                WHERE mapping.asset_uid = asset.uid
+            ) THEN 'source_mapping' END
+        ],
+        NULL
+    ) AS missing_fields,
+    EXISTS (
+        SELECT 1 FROM mapped_assets mapped
+        WHERE mapped.asset_uid = asset.uid
+    ) AS mapped,
+    COALESCE(
+        (
+            SELECT array_agg(DISTINCT mapped.merge_uid::text ORDER BY mapped.merge_uid::text)
+            FROM mapped_assets mapped
+            WHERE mapped.asset_uid = asset.uid
+        ),
+        ARRAY[]::text[]
+    ) AS merge_uids
+FROM visible_assets asset
+"""
+
+_ISSUE_DETAIL_SELECT = """
+SELECT
+    issue.uid::text AS issue_uid,
+    issue.issue_code,
+    issue.asset_uid::text AS asset_uid,
+    asset.name AS asset_name,
+    issue.rule_code,
+    issue.field_name,
+    issue.priority,
+    issue.status,
+    issue.occurrence_number,
+    issue.assignee_uid::text AS assignee_uid,
+    issue.due_at,
+    issue.closed_at,
+    (
+        issue.status <> 'closed'
+        AND issue.due_at IS NOT NULL
+        AND issue.due_at < CURRENT_TIMESTAMP
+    ) AS overdue
+FROM visible_issues issue
+JOIN visible_assets asset ON asset.uid = issue.asset_uid
+"""
+
+_ASSET_STATE_PREDICATES = {
+    ("asset_completeness", "complete"): (
+        "NULLIF(BTRIM(asset.name), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.location), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL "
+        "AND EXISTS (SELECT 1 FROM visible_mappings mapping "
+        "WHERE mapping.asset_uid = asset.uid)"
+    ),
+    ("asset_completeness", "incomplete"): (
+        "NOT (NULLIF(BTRIM(asset.name), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.location), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL "
+        "AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL "
+        "AND EXISTS (SELECT 1 FROM visible_mappings mapping "
+        "WHERE mapping.asset_uid = asset.uid))"
+    ),
+    ("responsibility_coverage", "covered"): (
+        "NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL"
+    ),
+    ("responsibility_coverage", "uncovered"): (
+        "NULLIF(BTRIM(asset.responsible_person), '') IS NULL"
+    ),
+    ("entity_mapping", "mapped"): (
+        "EXISTS (SELECT 1 FROM mapped_assets mapped "
+        "WHERE mapped.asset_uid = asset.uid)"
+    ),
+    ("entity_mapping", "unmapped"): (
+        "NOT EXISTS (SELECT 1 FROM mapped_assets mapped "
+        "WHERE mapped.asset_uid = asset.uid)"
+    ),
+}
+
+_ISSUE_STATE_PREDICATES = {
+    ("issue_closure", "closed"): "issue.status = 'closed'",
+    ("issue_closure", "open"): "issue.status <> 'closed'",
+    ("issue_recurrence", "recurrent"): "issue.occurrence_number > 1",
+    ("issue_recurrence", "first_occurrence"): "issue.occurrence_number = 1",
+}
+
+
+class SqlAlchemyGovernanceMetricRepository:
+    """Calculate and trace metrics without returning unauthorized raw records."""
+
+    def __init__(self, session):
+        self.session = session
+
+    @staticmethod
+    def _params(access, **extra):
+        return {
+            "global_access": bool(access.global_access),
+            "business_domain_uids": list(access.business_domain_uids),
+            **extra,
+        }
+
+    def summary_counts(self, access):
+        row = (
+            self.session.execute(
+                text(
+                    _SCOPE_CTES
+                    + """
+                    SELECT
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_assets asset
+                            WHERE NULLIF(BTRIM(asset.name), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.location), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.organization), '') IS NOT NULL
+                              AND NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL
+                              AND EXISTS (
+                                  SELECT 1
+                                  FROM visible_mappings mapping
+                                  WHERE mapping.asset_uid = asset.uid
+                              )
+                        ) AS complete_assets,
+                        (SELECT COUNT(*) FROM visible_assets) AS total_assets,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_assets asset
+                            WHERE NULLIF(BTRIM(asset.responsible_person), '') IS NOT NULL
+                        ) AS responsible_assets,
+                        (
+                            SELECT COUNT(DISTINCT asset_uid)
+                            FROM mapped_assets
+                        ) AS mapped_assets,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_issues
+                            WHERE status = 'closed'
+                        ) AS closed_issues,
+                        (SELECT COUNT(*) FROM visible_issues) AS total_issues,
+                        (
+                            SELECT COUNT(*)
+                            FROM visible_issues
+                            WHERE occurrence_number > 1
+                        ) AS recurrent_issues
+                    """
+                ),
+                self._params(access),
+            )
+            .mappings()
+            .one()
+        )
+        total_assets = int(row["total_assets"])
+        total_issues = int(row["total_issues"])
+        return {
+            "asset_completeness": (
+                int(row["complete_assets"]),
+                total_assets,
+            ),
+            "responsibility_coverage": (
+                int(row["responsible_assets"]),
+                total_assets,
+            ),
+            "entity_mapping": (int(row["mapped_assets"]), total_assets),
+            "issue_closure": (int(row["closed_issues"]), total_issues),
+            "issue_recurrence": (
+                int(row["recurrent_issues"]),
+                total_issues,
+            ),
+        }
+
+    @staticmethod
+    def _safe_row(row):
+        result = dict(row)
+        for field in ("due_at", "closed_at"):
+            value = result.get(field)
+            if isinstance(value, datetime):
+                result[field] = value.isoformat()
+        for field in ("source_codes", "missing_fields", "merge_uids"):
+            if field in result:
+                result[field] = list(result[field] or [])
+        return result
+
+    def list_details(self, access, *, metric, state, page, page_size):
+        predicate = _ASSET_STATE_PREDICATES.get((metric, state))
+        select_clause = _ASSET_DETAIL_SELECT
+        order_clause = "ORDER BY asset.name, asset.uid"
+        if predicate is None:
+            predicate = _ISSUE_STATE_PREDICATES[(metric, state)]
+            select_clause = _ISSUE_DETAIL_SELECT
+            order_clause = "ORDER BY issue.created_at DESC, issue.uid DESC"
+
+        filtered_query = (
+            _SCOPE_CTES
+            + ", filtered_details AS ("
+            + select_clause
+            + f" WHERE {predicate}) "
+        )
+        total = int(
+            self.session.execute(
+                text(filtered_query + "SELECT COUNT(*) FROM filtered_details"),
+                self._params(access),
+            ).scalar()
+            or 0
+        )
+        offset = (page - 1) * page_size
+        rows = (
+            self.session.execute(
+                text(
+                    _SCOPE_CTES
+                    + select_clause
+                    + f" WHERE {predicate} {order_clause} "
+                    + "LIMIT :limit OFFSET :offset"
+                ),
+                self._params(access, limit=page_size, offset=offset),
+            )
+            .mappings()
+            .all()
+        )
+        return [self._safe_row(row) for row in rows], total

+ 159 - 0
app/core/data_research/governance_metrics.py

@@ -0,0 +1,159 @@
+"""Read-only governance operational metric contracts and orchestration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+from app.core.data_research.errors import GovernanceMetricInvalid
+
+MAX_PAGE_SIZE = 100
+
+METRIC_DEFINITIONS = (
+    {
+        "code": "asset_completeness",
+        "name": "台账完整率",
+        "definition": (
+            "名称、位置、组织、责任人及授权有效来源映射均完整的在用设备"
+            "占可见在用设备的比例"
+        ),
+        "states": ("complete", "incomplete"),
+    },
+    {
+        "code": "responsibility_coverage",
+        "name": "责任覆盖率",
+        "definition": "已明确责任人的在用设备占可见在用设备的比例",
+        "states": ("covered", "uncovered"),
+    },
+    {
+        "code": "entity_mapping",
+        "name": "实体映射率",
+        "definition": (
+            "参与有效且未回滚实体合并的在用设备占可见在用设备的比例"
+        ),
+        "states": ("mapped", "unmapped"),
+    },
+    {
+        "code": "issue_closure",
+        "name": "问题闭环率",
+        "definition": "已关闭质量问题占可见质量问题总数的比例",
+        "states": ("closed", "open"),
+    },
+    {
+        "code": "issue_recurrence",
+        "name": "问题复发率",
+        "definition": "发生次数大于一次的质量问题占可见质量问题总数的比例",
+        "states": ("recurrent", "first_occurrence"),
+    },
+)
+
+METRICS_BY_CODE = {item["code"]: item for item in METRIC_DEFINITIONS}
+
+
+@dataclass(frozen=True)
+class GovernanceMetricAccess:
+    """Caller-visible business-domain boundary for aggregate and detail queries."""
+
+    global_access: bool
+    business_domain_uids: tuple[str, ...] = ()
+
+    def __post_init__(self):
+        normalized = tuple(
+            sorted(
+                {
+                    str(value).strip()
+                    for value in self.business_domain_uids
+                    if str(value).strip()
+                }
+            )
+        )
+        object.__setattr__(self, "business_domain_uids", normalized)
+
+
+def _positive_integer(value: Any, field: str, *, maximum: int | None = None) -> int:
+    try:
+        parsed = int(value)
+    except (TypeError, ValueError) as error:
+        raise GovernanceMetricInvalid(f"{field} must be a positive integer") from error
+    if parsed < 1 or (maximum is not None and parsed > maximum):
+        suffix = f" between 1 and {maximum}" if maximum is not None else ""
+        raise GovernanceMetricInvalid(f"{field} must be a positive integer{suffix}")
+    return parsed
+
+
+class GovernanceMetricsService:
+    """Expose fixed metric definitions over an authorization-aware repository."""
+
+    def __init__(self, repository):
+        self.repository = repository
+
+    @staticmethod
+    def _scope(access: GovernanceMetricAccess) -> dict[str, Any]:
+        return {
+            "global_access": access.global_access,
+            "business_domain_uids": list(access.business_domain_uids),
+        }
+
+    def summary(self, access: GovernanceMetricAccess) -> dict[str, Any]:
+        counts = self.repository.summary_counts(access)
+        metrics = []
+        for definition in METRIC_DEFINITIONS:
+            numerator, denominator = counts[definition["code"]]
+            metrics.append(
+                {
+                    "code": definition["code"],
+                    "name": definition["name"],
+                    "definition": definition["definition"],
+                    "numerator": int(numerator),
+                    "denominator": int(denominator),
+                    "rate": (
+                        round(int(numerator) / int(denominator), 6)
+                        if denominator
+                        else None
+                    ),
+                    "status": "available" if denominator else "no_data",
+                    "states": list(definition["states"]),
+                }
+            )
+        return {"scope": self._scope(access), "metrics": metrics}
+
+    def details(
+        self,
+        access: GovernanceMetricAccess,
+        *,
+        metric: str,
+        state: str,
+        page: Any = 1,
+        page_size: Any = 20,
+    ) -> dict[str, Any]:
+        definition = METRICS_BY_CODE.get(str(metric or "").strip())
+        if definition is None:
+            raise GovernanceMetricInvalid("unsupported metric")
+        normalized_state = str(state or "").strip()
+        if normalized_state not in definition["states"]:
+            raise GovernanceMetricInvalid("unsupported state for metric")
+        normalized_page = _positive_integer(page, "page")
+        normalized_page_size = _positive_integer(
+            page_size,
+            "page_size",
+            maximum=MAX_PAGE_SIZE,
+        )
+        records, total = self.repository.list_details(
+            access,
+            metric=definition["code"],
+            state=normalized_state,
+            page=normalized_page,
+            page_size=normalized_page_size,
+        )
+        return {
+            "metric": {
+                "code": definition["code"],
+                "name": definition["name"],
+                "definition": definition["definition"],
+            },
+            "state": normalized_state,
+            "records": list(records),
+            "page": normalized_page,
+            "page_size": normalized_page_size,
+            "total": int(total),
+        }

+ 32 - 0
app/core/data_research/graph_projection.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from app.core.data_research.errors import DataElementProjectionInvalid
+
+
+class DataElementGraphProjector:
+    """Idempotent effective-graph projection for published data elements."""
+
+    STATEMENT = """
+    MERGE (element:DataElement {uid: $uid})
+    SET element.code = $code,
+        element.name_zh = $name_zh,
+        element.version = $version,
+        element.status = 'published'
+    """
+
+    def __init__(self, graph_session):
+        self.graph_session = graph_session
+
+    def project(self, payload):
+        if str(payload.get("status") or "") != "published":
+            raise DataElementProjectionInvalid(
+                "only a published data element version can be projected"
+            )
+        snapshot = dict(payload.get("snapshot") or {})
+        self.graph_session.run(
+            self.STATEMENT,
+            uid=str(payload["uid"]),
+            version=int(payload["version"]),
+            code=str(snapshot.get("code") or ""),
+            name_zh=str(snapshot.get("name_zh") or ""),
+        )

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio