From bd041baf9b458644320ab4e9e2b751970ad9f7ac Mon Sep 17 00:00:00 2001 From: codex Date: Sun, 1 Nov 2026 22:56:19 +0100 Subject: [PATCH] docs: preserve reviewed NetKingdom session artifacts --- .../state-hub/api/models/fabric_graph.py | 149 +++++ .../state-hub/api/models/intake.py | 129 +++++ .../state-hub/api/models/interface_change.py | 53 ++ .../state-hub/api/models/legacy_meter.py | 79 +++ .../state-hub/api/models/managed_repo.py | 73 +++ .../state-hub/api/models/memo_notification.py | 37 ++ .../state-hub/api/models/progress_event.py | 49 ++ .../state-hub/api/models/repo_goal.py | 49 ++ .../state-hub/api/models/repository_rename.py | 292 ++++++++++ .../state-hub/api/models/review_contract.py | 128 +++++ .../state-hub/api/models/sbom_entry.py | 59 ++ .../state-hub/api/models/sbom_snapshot.py | 32 ++ .../state-hub/api/models/service_catalog.py | 121 ++++ .../state-hub/api/models/suggestion.py | 124 ++++ .../state-hub/api/models/task.py | 79 +++ .../state-hub/api/models/technical_debt.py | 95 ++++ .../state-hub/api/models/token_event.py | 92 +++ .../state-hub/api/models/topic.py | 54 ++ .../state-hub/api/models/tpsc.py | 64 +++ .../models/work_record_identifier_alias.py | 44 ++ .../state-hub/api/models/workplan.py | 89 +++ .../api/models/workplan_dependency.py | 109 ++++ .../api/models/workplan_launch_request.py | 39 ++ .../state-hub/api/models/workstream.py | 6 + .../api/models/workstream_dependency.py | 6 + .../api/models/write_idempotency_key.py | 32 ++ .../state-hub/api/routers/__init__.py | 0 .../api/routers/capability_requests.py | 532 ++++++++++++++++++ .../api/routers/consistency_sweep.py | 37 ++ .../state-hub/api/routers/contributions.py | 137 +++++ .../state-hub/api/routers/decisions.py | 263 +++++++++ .../state-hub/api/routers/domain_goals.py | 135 +++++ .../state-hub/api/routers/domains.py | 102 ++++ .../state-hub/api/routers/execution.py | 232 ++++++++ .../state-hub/api/routers/extension_points.py | 105 ++++ .../state-hub/api/routers/fabric.py | 514 +++++++++++++++++ .../state-hub/api/routers/flows.py | 191 +++++++ .../api/routers/identifier_migrations.py | 118 ++++ .../state-hub/api/routers/intake.py | 173 ++++++ .../api/routers/interface_changes.py | 209 +++++++ .../state-hub/api/routers/legacy_meter.py | 164 ++++++ .../api/routers/memo_notifications.py | 97 ++++ .../state-hub/api/routers/message_feed.py | 277 +++++++++ .../state-hub/api/routers/messages.py | 346 ++++++++++++ .../state-hub/api/routers/ops_runs.py | 13 + .../state-hub/api/routers/policy.py | 34 ++ .../state-hub/api/routers/progress.py | 58 ++ .../api/routers/recently_on_scope.py | 74 +++ .../state-hub/api/routers/reconciliation.py | 376 +++++++++++++ .../state-hub/api/routers/repo_goals.py | 77 +++ 50 files changed, 6347 insertions(+) create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/fabric_graph.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/intake.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/interface_change.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/legacy_meter.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/managed_repo.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/memo_notification.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/progress_event.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/repo_goal.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/repository_rename.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/review_contract.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/sbom_entry.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/sbom_snapshot.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/service_catalog.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/suggestion.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/task.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/technical_debt.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/token_event.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/topic.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/tpsc.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/work_record_identifier_alias.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan_dependency.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan_launch_request.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workstream.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workstream_dependency.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/write_idempotency_key.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/__init__.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/capability_requests.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/consistency_sweep.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/contributions.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/decisions.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/domain_goals.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/domains.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/execution.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/extension_points.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/fabric.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/flows.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/identifier_migrations.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/intake.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/interface_changes.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/legacy_meter.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/memo_notifications.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/message_feed.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/messages.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/ops_runs.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/policy.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/progress.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/recently_on_scope.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/reconciliation.py create mode 100644 docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repo_goals.py diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/fabric_graph.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/fabric_graph.py new file mode 100644 index 00000000..746447b3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/fabric_graph.py @@ -0,0 +1,149 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class FabricGraphImport(Base, TimestampMixin): + __tablename__ = "fabric_graph_imports" + __table_args__ = ( + UniqueConstraint( + "source_repo_slug", + "content_hash", + name="uq_fabric_graph_imports_source_hash", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + source_url: Mapped[str | None] = mapped_column(Text, nullable=True) + source_commit: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + source_path: Mapped[str | None] = mapped_column(Text, nullable=True) + api_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + schema_version: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + export_kind: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + exported_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + netkingdom_id: Mapped[str | None] = mapped_column(Text, nullable=True) + king_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True) + actor_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + fabric_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + unresolved_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + compatibility: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + node_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + edge_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + validation_status: Mapped[str] = mapped_column(String(20), nullable=False, default="valid", server_default="valid", index=True) + error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + graph_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + is_latest: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false", index=True) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + nodes: Mapped[list["FabricGraphNode"]] = relationship( + "FabricGraphNode", back_populates="import_run", cascade="all, delete-orphan", lazy="select" + ) + edges: Mapped[list["FabricGraphEdge"]] = relationship( + "FabricGraphEdge", back_populates="import_run", cascade="all, delete-orphan", lazy="select" + ) + + +class FabricGraphNode(Base): + __tablename__ = "fabric_graph_nodes" + __table_args__ = ( + UniqueConstraint("import_id", "graph_id", name="uq_fabric_graph_nodes_import_graph_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + import_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("fabric_graph_imports.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + graph_id: Mapped[str] = mapped_column(Text, nullable=False) + kind: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + name: Mapped[str] = mapped_column(Text, nullable=False) + repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + domain_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + lifecycle: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + canonical_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + canon_category: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + canon_anchor: Mapped[str | None] = mapped_column(Text, nullable=True) + mapping_fit: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + evidence_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + evidence_review_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + evidence_confidence: Mapped[float | None] = mapped_column(Float, nullable=True) + netkingdom_id: Mapped[str | None] = mapped_column(Text, nullable=True) + fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + environment: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + deployment_scenario_id: Mapped[str | None] = mapped_column(Text, nullable=True) + owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + owner_role: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + ownership_resolution: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + display_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false", index=True) + attributes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + raw_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + + import_run: Mapped["FabricGraphImport"] = relationship("FabricGraphImport", back_populates="nodes") + + +class FabricGraphEdge(Base): + __tablename__ = "fabric_graph_edges" + __table_args__ = ( + UniqueConstraint("import_id", "edge_key", name="uq_fabric_graph_edges_import_edge_key"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + import_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("fabric_graph_imports.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + source_repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + edge_key: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + from_graph_id: Mapped[str] = mapped_column(Text, nullable=False) + to_graph_id: Mapped[str] = mapped_column(Text, nullable=False) + edge_type: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + canonical_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + canon_anchor: Mapped[str | None] = mapped_column(Text, nullable=True) + mapping_fit: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + evidence_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + evidence_review_state: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + evidence_confidence: Mapped[float | None] = mapped_column(Float, nullable=True) + relationship_category: Mapped[str | None] = mapped_column(String(30), nullable=True, index=True) + provider_owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + provider_fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + provider_subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + consumer_owner_actor_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + consumer_fabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + consumer_subfabric_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + crosses_fabric_boundary: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True) + crosses_subfabric_boundary: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True) + utility_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + utility_contract_id: Mapped[str | None] = mapped_column(Text, nullable=True) + utility_payment_schema_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + utility_metering_basis: Mapped[str | None] = mapped_column(String(100), nullable=True) + utility_business_model: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + provider_profit_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + consumer_cost_center_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + display_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false", index=True) + attributes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + raw_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + + import_run: Mapped["FabricGraphImport"] = relationship("FabricGraphImport", back_populates="edges") diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/intake.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/intake.py new file mode 100644 index 00000000..a9cc84f0 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/intake.py @@ -0,0 +1,129 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from api.models.base import Base, TimestampMixin, new_uuid7 + + +class IntakeLane(str, enum.Enum): + green = "green" + blue = "blue" + yellow = "yellow" + orange = "orange" + red = "red" + + +class IntakeStatus(str, enum.Enum): + open = "open" + vetted = "vetted" + routed = "routed" + closed = "closed" + + +class IntakeOutcome(str, enum.Enum): + promoted = "promoted" + declined = "declined" + absorbed = "absorbed" + + +OPEN_INTAKE_STATUSES = (IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed) + + +class Intake(Base, TimestampMixin): + """A `kind: intake` work record — a spark: idea, finding, directive, or + request, per canon/standards/work-record-types_v0.1.md. Lifecycle: + open -> vetted -> routed -> closed(promoted|declined|absorbed). + + Fresh entity per the founder-reviewed architecture draft (2026-07-20, + WorkOrchestrationArchitectureDraft.md §8 item 6): not a rename/reuse of + the legacy `suggestions` table. + """ + + __tablename__ = "intakes" + __table_args__ = ( + CheckConstraint( + "topic_id IS NOT NULL OR workplan_id IS NOT NULL OR repo_id IS NOT NULL", + name="ck_intakes_topic_or_workplan_or_repo", + ), + CheckConstraint( + "(status != 'closed') OR (outcome IS NOT NULL)", + name="ck_intakes_closed_requires_outcome", + ), + CheckConstraint( + "(outcome != 'promoted') OR (promoted_to IS NOT NULL)", + name="ck_intakes_promoted_requires_promoted_to", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid7 + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True, index=True + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, + ) + repo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + lane: Mapped[IntakeLane] = mapped_column( + Enum(IntakeLane, name="intakelane"), nullable=False, default=IntakeLane.green + ) + status: Mapped[IntakeStatus] = mapped_column( + Enum(IntakeStatus, name="intakestatus"), + nullable=False, + default=IntakeStatus.open, + index=True, + ) + outcome: Mapped[IntakeOutcome | None] = mapped_column( + Enum(IntakeOutcome, name="intakeoutcome"), nullable=True + ) + origin: Mapped[str | None] = mapped_column(String(200), nullable=True) + origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True) + promoted_to: Mapped[str | None] = mapped_column(String(200), nullable=True) + source_repo_path: Mapped[str | None] = mapped_column( + Text, nullable=True, doc="Repo-relative path of the source file this record was authored in." + ) + routed_note: Mapped[str | None] = mapped_column(Text, nullable=True) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821 + repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 + notes: Mapped[list["IntakeNote"]] = relationship( + "IntakeNote", + back_populates="intake", + lazy="selectin", + order_by="IntakeNote.created_at", + cascade="all, delete-orphan", + ) + + +class IntakeNote(Base): + __tablename__ = "intake_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid7) + intake_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("intakes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + author: Mapped[str | None] = mapped_column(String(100), nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + intake: Mapped["Intake"] = relationship("Intake", back_populates="notes") diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/interface_change.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/interface_change.py new file mode 100644 index 00000000..9d6652f7 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/interface_change.py @@ -0,0 +1,53 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, Index, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class InterfaceChange(Base, TimestampMixin): + __tablename__ = "interface_changes" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + interface_type: Mapped[str] = mapped_column( + String(40), nullable=False + ) # rest_api | mcp_tool | cli | schema | capability + change_type: Mapped[str] = mapped_column( + String(40), nullable=False + ) # breaking | additive | deprecation | removal + title: Mapped[str] = mapped_column(String(300), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + affected_paths: Mapped[list] = mapped_column( + JSONB, nullable=False, default=list, server_default="[]" + ) + affected_repo_slugs: Mapped[list] = mapped_column( + JSONB, nullable=False, default=list, server_default="[]" + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="draft", index=True + ) # draft | published | resolved + planned_for: Mapped[date | None] = mapped_column(Date, nullable=True) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + resolved_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + author: Mapped[str] = mapped_column(String(100), nullable=False, default="custodian") + + repo: Mapped["ManagedRepo"] = relationship( # noqa: F821 + "ManagedRepo", lazy="selectin" + ) + + __table_args__ = ( + Index("ix_interface_changes_repo_status", "repo_id", "status"), + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/legacy_meter.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/legacy_meter.py new file mode 100644 index 00000000..68abea83 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/legacy_meter.py @@ -0,0 +1,79 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class LegacyInterface(Base, TimestampMixin): + __tablename__ = "legacy_interfaces" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + interface_key: Mapped[str] = mapped_column(String(300), nullable=False, unique=True, index=True) + interface_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True) + legacy_since: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + replacement_ref: Mapped[str] = mapped_column(Text, nullable=False) + owner_component: Mapped[str] = mapped_column( + String(100), nullable=False, default="state-hub", server_default="state-hub", index=True + ) + status: Mapped[str] = mapped_column( + String(30), nullable=False, default="legacy", server_default="legacy", index=True + ) + replacement_verified: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + manual_hold: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False, server_default="false" + ) + hold_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + retired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + usage_buckets: Mapped[list["LegacyInterfaceUsageBucket"]] = relationship( # noqa: F821 + "LegacyInterfaceUsageBucket", + back_populates="legacy_interface", + cascade="all, delete-orphan", + lazy="selectin", + ) + + +class LegacyInterfaceUsageBucket(Base, TimestampMixin): + __tablename__ = "legacy_interface_usage_buckets" + __table_args__ = ( + UniqueConstraint( + "legacy_interface_id", + "period_start", + "bucket_kind", + "bucket_key", + name="uq_legacy_usage_bucket", + ), + Index("ix_legacy_usage_interface_period", "legacy_interface_id", "period_start"), + Index("ix_legacy_usage_bucket_kind_key", "bucket_kind", "bucket_key"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + legacy_interface_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("legacy_interfaces.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + period_start: Mapped[date] = mapped_column(Date, nullable=False, index=True) + bucket_kind: Mapped[str] = mapped_column(String(30), nullable=False) + bucket_key: Mapped[str] = mapped_column(String(200), nullable=False) + call_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + + legacy_interface: Mapped["LegacyInterface"] = relationship( # noqa: F821 + "LegacyInterface", back_populates="usage_buckets" + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/managed_repo.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/managed_repo.py new file mode 100644 index 00000000..993e2369 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/managed_repo.py @@ -0,0 +1,73 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class ManagedRepo(Base, TimestampMixin): + __tablename__ = "managed_repos" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False, index=True + ) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + local_path: Mapped[str | None] = mapped_column(Text, nullable=True) + host_paths: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + remote_url: Mapped[str | None] = mapped_column(Text, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="active") + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + git_fingerprint: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + sbom_source: Mapped[str | None] = mapped_column(Text, nullable=True) + last_sbom_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + last_state_synced_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + category: Mapped[str | None] = mapped_column(String(50), nullable=True) + secondary_domains: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) + capability_tags: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) + business_stake: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) + business_mechanics: Mapped[list[str] | None] = mapped_column(ARRAY(Text), nullable=True) + classified_at: Mapped[date | None] = mapped_column(Date, nullable=True) + classified_by: Mapped[str | None] = mapped_column(String(50), nullable=True) + standard_version: Mapped[str | None] = mapped_column(String(20), nullable=True) + + domain: Mapped["Domain"] = relationship( # noqa: F821 + "Domain", back_populates="repos", lazy="selectin" + ) + + goals: Mapped[list["RepoGoal"]] = relationship( # noqa: F821 + "RepoGoal", back_populates="repo", lazy="selectin" + ) + + forge_identity: Mapped["RepositoryForgeIdentity | None"] = relationship( # noqa: F821 + "RepositoryForgeIdentity", + back_populates="repo", + lazy="selectin", + uselist=False, + ) + + slug_records: Mapped[list["RepositorySlug"]] = relationship( # noqa: F821 + "RepositorySlug", back_populates="repo", lazy="selectin" + ) + + rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( # noqa: F821 + "RepositoryRenameOperation", back_populates="repo", lazy="selectin" + ) + + @property + def domain_slug(self) -> str: + return self.domain.slug if self.domain is not None else "" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/memo_notification.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/memo_notification.py new file mode 100644 index 00000000..18d9c555 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/memo_notification.py @@ -0,0 +1,37 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from api.models.base import Base + + +class MemoNotificationReceipt(Base): + """Durable receipt for one informed-decision delivery id. + + The delivery id is the sender's stable idempotency key. The body is only + memo id, question, class, and submitter. Rows are retained, not expired. + """ + + __tablename__ = "memo_notification_receipts" + + delivery_id: Mapped[str] = mapped_column(String(128), primary_key=True) + body_digest: Mapped[str] = mapped_column(String(64), nullable=False) + memo_id: Mapped[str] = mapped_column(String(63), nullable=False) + question: Mapped[str] = mapped_column(String(400), nullable=False) + memo_class: Mapped[str] = mapped_column(String(63), nullable=False) + submitter: Mapped[str] = mapped_column(String(63), nullable=False) + message_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("agent_messages.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + authoritative_review: Mapped[str] = mapped_column( + String(40), nullable=False, default="signed-in-overview" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/progress_event.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/progress_event.py new file mode 100644 index 00000000..b73fe60f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/progress_event.py @@ -0,0 +1,49 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, new_uuid + + +class ProgressEvent(Base): + """Append-only event log. No updated_at. No DELETE endpoint (constitution §5).""" + + __tablename__ = "progress_events" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, + ) + task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, + ) + decision_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="RESTRICT"), nullable=True, index=True + ) + event_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + summary: Mapped[str] = mapped_column(Text, nullable=False) + detail: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + author: Mapped[str | None] = mapped_column(String(100), nullable=True) + session_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + + topic: Mapped["Topic | None"] = relationship("Topic", back_populates="progress_events") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="progress_events") # noqa: F821 + task: Mapped["Task | None"] = relationship("Task", back_populates="progress_events") # noqa: F821 + decision: Mapped["Decision | None"] = relationship("Decision", back_populates="progress_events") # noqa: F821 diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/repo_goal.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/repo_goal.py new file mode 100644 index 00000000..352d5bd5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/repo_goal.py @@ -0,0 +1,49 @@ +import enum +import uuid + +from sqlalchemy import ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class RepoGoalStatus(str, enum.Enum): + active = "active" + paused = "paused" + completed = "completed" + archived = "archived" + + +class RepoGoal(Base, TimestampMixin): + __tablename__ = "repo_goals" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="RESTRICT"), nullable=False, index=True + ) + domain_goal_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("domain_goals.id", ondelete="SET NULL"), nullable=True, index=True + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100, server_default="100") + status: Mapped[str] = mapped_column( + String(20), nullable=False, default=RepoGoalStatus.active.value, server_default="active" + ) + + repo: Mapped["ManagedRepo"] = relationship( # noqa: F821 + "ManagedRepo", back_populates="goals", lazy="selectin" + ) + domain_goal: Mapped["DomainGoal"] = relationship( # noqa: F821 + "DomainGoal", back_populates="repo_goals", lazy="selectin" + ) + workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821 + "Workplan", back_populates="repo_goal", lazy="selectin" + ) + + @property + def repo_slug(self) -> str: + return self.repo.slug if self.repo is not None else "" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/repository_rename.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/repository_rename.py new file mode 100644 index 00000000..b27a92c3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/repository_rename.py @@ -0,0 +1,292 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + Boolean, + CheckConstraint, + DateTime, + ForeignKey, + ForeignKeyConstraint, + Index, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +FORGE_IDENTITY_STATES = ("unverified", "verified") +REPOSITORY_SLUG_KINDS = ("canonical", "alias") +REPOSITORY_RENAME_PHASES = ( + "draft", + "preflighted", + "forge-renamed", + "statehub-rebound", + "source-synced", + "consumers-verified", + "completed", + "rollback-preflight", + "rolled-back", +) +TERMINAL_REPOSITORY_RENAME_PHASES = ("completed", "rolled-back") + + +class RepositoryForgeIdentity(Base, TimestampMixin): + """One explicitly verified (or explicitly unverified) Forge identity per repo.""" + + __tablename__ = "repository_forge_identities" + __table_args__ = ( + UniqueConstraint("repo_id", name="uq_repository_forge_identities_repo_id"), + UniqueConstraint( + "id", + "repo_id", + "verification_state", + name="uq_repository_forge_identity_verified_ref", + ), + UniqueConstraint( + "provider", + "forge_instance", + "forge_owner", + "forge_repository_id", + name="uq_repository_forge_identity_tuple", + ), + CheckConstraint( + "verification_state IN ('unverified', 'verified')", + name="ck_repository_forge_identity_state", + ), + CheckConstraint( + "forge_repository_id IS NULL OR forge_repository_id > 0", + name="ck_repository_forge_id_positive", + ), + CheckConstraint( + "verification_state != 'verified' OR " + "(provider IS NOT NULL AND forge_instance IS NOT NULL " + "AND forge_owner IS NOT NULL AND forge_repository_id IS NOT NULL " + "AND verified_at IS NOT NULL AND verified_by IS NOT NULL)", + name="ck_repository_forge_verified_complete", + ), + CheckConstraint( + "verification_state = 'verified' OR " + "(verified_at IS NULL AND verified_by IS NULL)", + name="ck_repository_forge_unverified_has_no_attestation", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + ) + provider: Mapped[str | None] = mapped_column(String(40), nullable=True) + forge_instance: Mapped[str | None] = mapped_column(String(255), nullable=True) + forge_owner: Mapped[str | None] = mapped_column(String(255), nullable=True) + forge_repository_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + verification_state: Mapped[str] = mapped_column( + String(20), nullable=False, default="unverified", server_default="unverified" + ) + verified_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + verified_by: Mapped[str | None] = mapped_column(String(160), nullable=True) + verification_evidence: Mapped[dict] = mapped_column( + JSONB, nullable=False, default=dict, server_default="{}" + ) + + repo: Mapped["ManagedRepo"] = relationship( # noqa: F821 + "ManagedRepo", back_populates="forge_identity", lazy="selectin" + ) + rename_operations: Mapped[list["RepositoryRenameOperation"]] = relationship( + "RepositoryRenameOperation", + back_populates="forge_identity", + lazy="selectin", + viewonly=True, + ) + + +class RepositoryRenameOperation(Base, TimestampMixin): + """Append-preserving journal for one phased canonical-coordinate change.""" + + __tablename__ = "repository_rename_operations" + __table_args__ = ( + CheckConstraint( + "phase IN ('draft', 'preflighted', 'forge-renamed', " + "'statehub-rebound', 'source-synced', 'consumers-verified', " + "'completed', 'rollback-preflight', 'rolled-back')", + name="ck_repository_rename_phase", + ), + CheckConstraint( + "old_slug != new_slug", name="ck_repository_rename_distinct_slugs" + ), + CheckConstraint( + "expected_forge_repository_id > 0", + name="ck_repository_rename_forge_id_positive", + ), + CheckConstraint( + "forge_identity_state = 'verified'", + name="ck_repository_rename_verified_identity", + ), + CheckConstraint( + "phase = 'draft' OR preflighted_at IS NOT NULL", + name="ck_repository_rename_preflight_timestamp", + ), + CheckConstraint( + "phase != 'completed' OR completed_at IS NOT NULL", + name="ck_repository_rename_completed_timestamp", + ), + CheckConstraint( + "phase != 'rolled-back' OR rolled_back_at IS NOT NULL", + name="ck_repository_rename_rollback_timestamp", + ), + Index( + "uq_repository_rename_active_repo", + "repo_id", + unique=True, + postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"), + ), + Index( + "uq_repository_rename_active_new_slug", + "new_slug", + unique=True, + postgresql_where=text("phase NOT IN ('completed', 'rolled-back')"), + ), + Index( + "ix_repository_rename_phase", "phase" + ), + ForeignKeyConstraint( + ["forge_identity_id", "repo_id", "forge_identity_state"], + [ + "repository_forge_identities.id", + "repository_forge_identities.repo_id", + "repository_forge_identities.verification_state", + ], + name="fk_repository_rename_verified_identity", + ondelete="RESTRICT", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + forge_identity_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), nullable=False + ) + forge_identity_state: Mapped[str] = mapped_column( + String(20), nullable=False, default="verified", server_default="verified" + ) + expected_provider: Mapped[str] = mapped_column(String(40), nullable=False) + expected_forge_instance: Mapped[str] = mapped_column(String(255), nullable=False) + expected_forge_owner: Mapped[str] = mapped_column(String(255), nullable=False) + expected_forge_repository_id: Mapped[int] = mapped_column( + BigInteger, nullable=False + ) + expected_source_commit: Mapped[str] = mapped_column(String(64), nullable=False) + expected_default_branch: Mapped[str] = mapped_column(String(255), nullable=False) + old_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + new_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + old_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False) + new_coordinates: Mapped[dict] = mapped_column(JSONB, nullable=False) + phase: Mapped[str] = mapped_column( + String(32), nullable=False, default="draft", server_default="draft" + ) + actor: Mapped[str] = mapped_column(String(160), nullable=False) + phase_changed_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + preflighted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + preflight_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + rolled_back_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + evidence: Mapped[dict] = mapped_column( + JSONB, nullable=False, default=dict, server_default="{}" + ) + error_code: Mapped[str | None] = mapped_column(String(80), nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + error_details: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + error_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + repo: Mapped["ManagedRepo"] = relationship( # noqa: F821 + "ManagedRepo", back_populates="rename_operations", lazy="selectin" + ) + forge_identity: Mapped[RepositoryForgeIdentity] = relationship( + "RepositoryForgeIdentity", + back_populates="rename_operations", + lazy="selectin", + viewonly=True, + ) + slugs: Mapped[list["RepositorySlug"]] = relationship( + "RepositorySlug", back_populates="source_operation", lazy="selectin" + ) + + +class RepositorySlug(Base, TimestampMixin): + """Global uniqueness boundary for current and protected prior slugs.""" + + __tablename__ = "repository_slugs" + __table_args__ = ( + UniqueConstraint("slug", name="uq_repository_slugs_slug"), + CheckConstraint( + "kind IN ('canonical', 'alias')", name="ck_repository_slug_kind" + ), + CheckConstraint( + "kind != 'alias' OR protected", + name="ck_repository_slug_alias_protected", + ), + Index( + "uq_repository_slugs_one_canonical", + "repo_id", + unique=True, + postgresql_where=text("kind = 'canonical'"), + ), + Index("ix_repository_slugs_repo_kind", "repo_id", "kind"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + ) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + kind: Mapped[str] = mapped_column(String(16), nullable=False) + protected: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + source_operation_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("repository_rename_operations.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + + repo: Mapped["ManagedRepo"] = relationship( # noqa: F821 + "ManagedRepo", back_populates="slug_records", lazy="selectin" + ) + source_operation: Mapped[RepositoryRenameOperation | None] = relationship( + "RepositoryRenameOperation", back_populates="slugs", lazy="selectin" + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/review_contract.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/review_contract.py new file mode 100644 index 00000000..392235e8 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/review_contract.py @@ -0,0 +1,128 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + Index, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid7 + + +class ReviewContract(Base, TimestampMixin): + """Replaceable projection of one authoritative review-contract revision.""" + + __tablename__ = "review_contracts" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid7 + ) + contract_key: Mapped[str] = mapped_column(String(160), nullable=False, index=True) + schema_version: Mapped[str] = mapped_column(String(40), nullable=False) + contract_digest: Mapped[str] = mapped_column( + String(64), nullable=False, unique=True + ) + source_repo: Mapped[str] = mapped_column(String(100), nullable=False) + source_path: Mapped[str] = mapped_column(Text, nullable=False) + source_revision: Mapped[str] = mapped_column(String(64), nullable=False) + document: Mapped[dict] = mapped_column(JSONB, nullable=False) + active: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, index=True + ) + required_for_decision: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=False + ) + decision_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("decisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, + ) + task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=True, + index=True, + ) + projected_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + receipts: Mapped[list["ReviewReceipt"]] = relationship( + "ReviewReceipt", back_populates="contract", lazy="selectin" + ) + + __table_args__ = ( + UniqueConstraint( + "contract_key", + "source_repo", + "source_path", + "source_revision", + name="uq_review_contract_source_revision", + ), + Index("ix_review_contract_key_active", "contract_key", "active"), + ) + + +class ReviewReceipt(Base, TimestampMixin): + """Immutable evidence submitted against an exact contract digest.""" + + __tablename__ = "review_receipts" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid7 + ) + contract_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("review_contracts.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + owner_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True) + actor: Mapped[str] = mapped_column(String(160), nullable=False) + disposition: Mapped[str] = mapped_column(String(32), nullable=False) + contract_digest: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + receipt_digest: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + artifact_hashes: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + assertion_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + checks: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + source_repo: Mapped[str] = mapped_column(String(100), nullable=False) + source_path: Mapped[str] = mapped_column(Text, nullable=False) + source_revision: Mapped[str] = mapped_column(String(64), nullable=False) + submitted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + document: Mapped[dict] = mapped_column(JSONB, nullable=False) + + contract: Mapped[ReviewContract] = relationship( + "ReviewContract", back_populates="receipts" + ) + + __table_args__ = ( + UniqueConstraint( + "source_repo", + "source_path", + "source_revision", + name="uq_review_receipt_source_revision", + ), + Index( + "ix_review_receipt_contract_owner_time", + "contract_id", + "owner_id", + "submitted_at", + ), + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/sbom_entry.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/sbom_entry.py new file mode 100644 index 00000000..70a15b14 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/sbom_entry.py @@ -0,0 +1,59 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, new_uuid + + +class Ecosystem(str, enum.Enum): + python = "python" + node = "node" + rust = "rust" + go = "go" + java = "java" + terraform = "terraform" + ansible = "ansible" + tool = "tool" + other = "other" + + +class SBOMEntry(Base): + """Snapshot-based SBOM entry — no updated_at; new ingest replaces old rows.""" + __tablename__ = "sbom_entries" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, index=True, + ) + package_name: Mapped[str] = mapped_column(String(300), nullable=False) + package_version: Mapped[str | None] = mapped_column(String(100), nullable=True) + ecosystem: Mapped[Ecosystem] = mapped_column( + Enum(Ecosystem, name="ecosystem"), nullable=False + ) + license_spdx: Mapped[str | None] = mapped_column(String(100), nullable=True) + is_direct: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_dev: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + snapshot_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("sbom_snapshots.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + snapshot_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 + snapshot: Mapped["SBOMSnapshot"] = relationship( # noqa: F821 + "SBOMSnapshot", lazy="selectin", back_populates="entries" + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/sbom_snapshot.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/sbom_snapshot.py new file mode 100644 index 00000000..538dd6eb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/sbom_snapshot.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, new_uuid + + +class SBOMSnapshot(Base): + """Container entity for a point-in-time SBOM scan of a repository (GEMS Complex).""" + __tablename__ = "sbom_snapshots" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + source: Mapped[str | None] = mapped_column(String(200), nullable=True) + entry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 + entries: Mapped[list["SBOMEntry"]] = relationship( # noqa: F821 + "SBOMEntry", lazy="select", back_populates="snapshot" + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/service_catalog.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/service_catalog.py new file mode 100644 index 00000000..dd1da88d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/service_catalog.py @@ -0,0 +1,121 @@ +"""Two-dimension service catalog (STATE-WP-0062). + +Every service is classified along two orthogonal dimensions: + + - hosting_type: self_hosted (coulomb operates it) | cloud_hosted (consumed) + - development_type: first_party (coulomb develops it) | third_party (external) + +Common fields live in ``ServiceCatalog``; dimension-specific data composes via +1:1 extension tables (``service_id`` is both PK and FK), so a self-hosted +first-party service carries the self-hosted *and* first-party extensions without +needing a bespoke per-class shape. +""" +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSON, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base + + +class ServiceCatalog(Base): + __tablename__ = "service_catalog" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + owner_or_provider: Mapped[str | None] = mapped_column(String(200), nullable=True) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + website_url: Mapped[str | None] = mapped_column(Text, nullable=True) + # status: active | deprecated + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active") + # hosting_type: self_hosted | cloud_hosted + hosting_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + # development_type: first_party | third_party + development_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + # Service DoM Level (1=Operable, 2=Observable, 3=Mature); NULL = unassessed + maturity_level: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + third_party: Mapped["ServiceThirdParty | None"] = relationship( + back_populates="service", uselist=False, cascade="all, delete-orphan") + first_party: Mapped["ServiceFirstParty | None"] = relationship( + back_populates="service", uselist=False, cascade="all, delete-orphan") + cloud: Mapped["ServiceCloud | None"] = relationship( + back_populates="service", uselist=False, cascade="all, delete-orphan") + self_hosted: Mapped["ServiceSelfHosted | None"] = relationship( + back_populates="service", uselist=False, cascade="all, delete-orphan") + + +class ServiceThirdParty(Base): + """Extension for development_type = third_party (coulomb is not dev-responsible).""" + __tablename__ = "service_third_party" + + service_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True) + # pricing_model: free | paid | freemium | usage_based | unknown + pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown") + upstream_packages: Mapped[list | None] = mapped_column(JSON, nullable=True) + upstream_contacts: Mapped[list | None] = mapped_column(JSON, nullable=True) + source_url: Mapped[str | None] = mapped_column(Text, nullable=True) + support_url: Mapped[str | None] = mapped_column(Text, nullable=True) + license: Mapped[str | None] = mapped_column(String(100), nullable=True) + + service: Mapped["ServiceCatalog"] = relationship(back_populates="third_party") + + +class ServiceFirstParty(Base): + """Extension for development_type = first_party (coulomb develops it).""" + __tablename__ = "service_first_party" + + service_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True) + repo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True) + owning_domain: Mapped[str | None] = mapped_column(String(100), nullable=True) + + service: Mapped["ServiceCatalog"] = relationship(back_populates="first_party") + + +class ServiceCloud(Base): + """Extension for hosting_type = cloud_hosted (data is processed off coulomb infra). + + Holds the data-processor concerns that were the heart of the old TPSC record; + they apply whenever data leaves coulomb infra, independent of who built it. + """ + __tablename__ = "service_cloud" + + service_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True) + # gdpr_maturity (CNIL/IAPP CMMI-aligned): + # unknown | non_compliant | initial | developing | defined | managed | certified + gdpr_maturity: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown", index=True) + gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + tos_url: Mapped[str | None] = mapped_column(Text, nullable=True) + privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True) + data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True) + data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + service: Mapped["ServiceCatalog"] = relationship(back_populates="cloud") + + +class ServiceSelfHosted(Base): + """Extension for hosting_type = self_hosted (coulomb operates the service).""" + __tablename__ = "service_self_hosted" + + service_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True) + # three-helix instance / host the service runs on + helix_instance: Mapped[str | None] = mapped_column(String(100), nullable=True) + host_node: Mapped[str | None] = mapped_column(String(100), nullable=True) + deployment_ref: Mapped[str | None] = mapped_column(Text, nullable=True) + runbook_ref: Mapped[str | None] = mapped_column(Text, nullable=True) + # upstream OSS project when the self-hosted service is third-party software + upstream_oss_project: Mapped[str | None] = mapped_column(String(200), nullable=True) + + service: Mapped["ServiceCatalog"] = relationship(back_populates="self_hosted") diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/suggestion.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/suggestion.py new file mode 100644 index 00000000..7b46abc5 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/suggestion.py @@ -0,0 +1,124 @@ +import enum +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Enum, Float, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from api.models.base import Base, TimestampMixin, new_uuid + + +class SuggestionStage(str, enum.Enum): + suggestion = "suggestion" + requirement = "requirement" + promoted = "promoted" + declined = "declined" + + +OPEN_SUGGESTION_STAGES = (SuggestionStage.suggestion, SuggestionStage.requirement) + + +class Suggestion(Base, TimestampMixin): + __tablename__ = "suggestions" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + origin: Mapped[str | None] = mapped_column(String(200), nullable=True) + origin_ref: Mapped[str | None] = mapped_column(String(200), nullable=True, index=True) + stage: Mapped[SuggestionStage] = mapped_column( + Enum(SuggestionStage, name="suggestionstage"), + nullable=False, + default=SuggestionStage.suggestion, + index=True, + ) + relevance: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0") + relevance_events: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + last_requested_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + base_value: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3") + job_size: Mapped[float] = mapped_column(Float, nullable=False, default=3.0, server_default="3") + relevance_weight: Mapped[float] = mapped_column( + Float, nullable=False, default=1.0, server_default="1" + ) + promoted_task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + ) + + domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 + topic: Mapped["Topic | None"] = relationship("Topic", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821 + promoted_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821 + notes: Mapped[list["SuggestionNote"]] = relationship( + "SuggestionNote", + back_populates="suggestion", + lazy="selectin", + order_by="SuggestionNote.created_at", + ) + + @property + def domain_slug(self) -> str: + return self.domain.slug if self.domain is not None else "" + + +class SuggestionNote(Base): + __tablename__ = "suggestion_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid) + suggestion_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("suggestions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + stage: Mapped[str] = mapped_column(String(30), nullable=False) + author: Mapped[str | None] = mapped_column(String(100), nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + suggestion: Mapped["Suggestion"] = relationship("Suggestion", back_populates="notes") + + +class SuggestionRelevanceBump(Base): + """Audit trail for relevance bumps; supports debounce lookups.""" + + __tablename__ = "suggestion_relevance_bumps" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid) + suggestion_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("suggestions.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + source: Mapped[str] = mapped_column(String(50), nullable=False) + source_key: Mapped[str] = mapped_column(String(200), nullable=False) + reason: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/task.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/task.py new file mode 100644 index 00000000..389586b9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/task.py @@ -0,0 +1,79 @@ +import enum +import uuid +from datetime import date + +from sqlalchemy import Boolean, Date, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class TaskStatus(str, enum.Enum): + wait = "wait" + todo = "todo" + progress = "progress" + done = "done" + cancel = "cancel" + + +class TaskPriority(str, enum.Enum): + low = "low" + medium = "medium" + high = "high" + critical = "critical" + + +class Task(Base, TimestampMixin): + __tablename__ = "tasks" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + workplan_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"), + nullable=False, + index=True, + ) + # Canonical work-record identifier, e.g. "CUST-WP-0067-T01" (ADR-007). + # Nullable because rows created before STATE-WP-0083-T06 have none; only the + # repository files know the mapping. Without it a task can be matched only by + # title, so renaming a heading looks like one task vanishing and another + # appearing. + record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[TaskStatus] = mapped_column( + Enum(TaskStatus), nullable=False, default=TaskStatus.todo + ) + priority: Mapped[TaskPriority] = mapped_column( + Enum(TaskPriority), nullable=False, default=TaskPriority.medium + ) + assignee: Mapped[str | None] = mapped_column(String(100), nullable=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) + intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True) + # Record id of the tracked decision a human-gated wait hangs on, e.g. + # "CCR-2026-0004" (CUST-WP-0074 Case B). Free text: the decision may live + # in another repo and may not be indexed in the hub yet. + decision_id: Mapped[str | None] = mapped_column(String(120), nullable=True) + # Owner-return marker "message-from:" (CUST-WP-0074 rule 6) and a + # date after which the wait clears by itself; either counts as external. + blocked_on: Mapped[str | None] = mapped_column(String(200), nullable=True) + wait_until: Mapped[date | None] = mapped_column(Date, nullable=True) + parent_task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + ) + + workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="tasks") # noqa: F821 + subtasks: Mapped[list["Task"]] = relationship( + "Task", foreign_keys=[parent_task_id], lazy="selectin" + ) + progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 + "ProgressEvent", back_populates="task", lazy="selectin" + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/technical_debt.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/technical_debt.py new file mode 100644 index 00000000..334bf6a2 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/technical_debt.py @@ -0,0 +1,95 @@ +import enum +import uuid + +from sqlalchemy import DateTime, Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.sql import func + +from api.models.base import Base, TimestampMixin, new_uuid + + +class TDStatus(str, enum.Enum): + # Legacy general statuses + open = "open" + in_progress = "in_progress" + resolved = "resolved" + deferred = "deferred" + wont_fix = "wont_fix" + # Dashboard-improvement workflow steps + submitted = "submitted" + analyse = "analyse" + plan = "plan" + implement = "implement" + test = "test" + review = "review" + finished = "finished" + + +# Ordered workflow steps for dashboard-improvement suggestions +SUGGESTION_STEPS = ["submitted", "analyse", "plan", "implement", "test", "review", "finished"] + + +class TDNote(Base): + __tablename__ = "td_notes" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid) + td_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("technical_debt.id", ondelete="CASCADE"), + nullable=False, index=True, + ) + step: Mapped[str] = mapped_column(String(30), nullable=False) + author: Mapped[str | None] = mapped_column(String(100), nullable=True) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[DateTime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + td: Mapped["TechnicalDebt"] = relationship("TechnicalDebt", back_populates="notes") + + +class TechnicalDebt(Base, TimestampMixin): + __tablename__ = "technical_debt" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + td_id: Mapped[str | None] = mapped_column( + String(30), nullable=True, unique=True, index=True + ) # human-readable ref, e.g. TD-CUST-001 + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + location: Mapped[str | None] = mapped_column(String(500), nullable=True) + debt_type: Mapped[str] = mapped_column( + String(50), nullable=False, default="other" + ) # design | implementation | test | docs | dependencies | performance | security | other + severity: Mapped[str] = mapped_column(String(20), nullable=False, default="medium") + status: Mapped[TDStatus] = mapped_column( + Enum(TDStatus, name="tdstatus"), nullable=False, default=TDStatus.open + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + ) + + domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 + topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821 + notes: Mapped[list["TDNote"]] = relationship( + "TDNote", back_populates="td", lazy="selectin", + order_by="TDNote.created_at", + ) + + @property + def domain_slug(self) -> str: + return self.domain.slug if self.domain is not None else "" diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/token_event.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/token_event.py new file mode 100644 index 00000000..f1dfe79f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/token_event.py @@ -0,0 +1,92 @@ +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + DateTime, + Float, + ForeignKey, + Integer, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, new_uuid + + +class TokenEvent(Base): + __tablename__ = "token_events" + __table_args__ = ( + UniqueConstraint( + "measurement_kind", + "source_provider", + "source_id", + name="uq_token_events_source_identity", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, + ) + workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"), + nullable=True, + index=True, + ) + repo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True + ) + session_id: Mapped[str | None] = mapped_column(Text, nullable=True) + model: Mapped[str | None] = mapped_column(Text, nullable=True) + tokens_in: Mapped[int] = mapped_column(Integer, nullable=False) + tokens_out: Mapped[int] = mapped_column(Integer, nullable=False) + agent: Mapped[str | None] = mapped_column(Text, nullable=True) + ref_type: Mapped[str | None] = mapped_column(Text, nullable=True) + ref_id: Mapped[str | None] = mapped_column(Text, nullable=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) + measurement_kind: Mapped[str] = mapped_column( + Text, nullable=False, default="estimated", server_default="estimated", index=True + ) + source_provider: Mapped[str] = mapped_column( + Text, nullable=False, default="manual", server_default="manual", index=True + ) + source_id: Mapped[str | None] = mapped_column(Text, nullable=True, index=True) + source_path: Mapped[str | None] = mapped_column(Text, nullable=True) + source_created_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, index=True + ) + ingested_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + parser_version: Mapped[str | None] = mapped_column(Text, nullable=True) + confidence: Mapped[float] = mapped_column( + Float, nullable=False, default=0.35, server_default="0.35" + ) + cached_input_tokens: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + reasoning_output_tokens: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + raw_total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + cost_estimated_usd: Mapped[float | None] = mapped_column(Float, nullable=True) + raw_metadata: Mapped[dict[str, Any]] = mapped_column( + JSONB, nullable=False, default=dict, server_default="{}" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, index=True + ) + + task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821 + workplan: Mapped["Workplan | None"] = relationship("Workplan", lazy="selectin") # noqa: F821 + repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/topic.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/topic.py new file mode 100644 index 00000000..907a6cd3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/topic.py @@ -0,0 +1,54 @@ +import enum +import uuid + +from sqlalchemy import Enum, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class TopicStatus(str, enum.Enum): + active = "active" + paused = "paused" + archived = "archived" + + +class Topic(Base, TimestampMixin): + __tablename__ = "topics" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + status: Mapped[TopicStatus] = mapped_column( + Enum(TopicStatus), nullable=False, default=TopicStatus.active + ) + + domain: Mapped["Domain"] = relationship( # noqa: F821 + "Domain", back_populates="topics", lazy="selectin" + ) + workplans: Mapped[list["Workplan"]] = relationship( # noqa: F821 + "Workplan", back_populates="topic", lazy="selectin" + ) + decisions: Mapped[list["Decision"]] = relationship( # noqa: F821 + "Decision", back_populates="topic", lazy="selectin" + ) + progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 + "ProgressEvent", back_populates="topic", lazy="selectin" + ) + + @property + def domain_slug(self) -> str | None: + """Returns the domain slug string for serialization.""" + if self.domain is not None: + return self.domain.slug + return None diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/tpsc.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/tpsc.py new file mode 100644 index 00000000..1ed91282 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/tpsc.py @@ -0,0 +1,64 @@ +import uuid +from datetime import datetime +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSON, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base + + +class TPSCCatalog(Base): + __tablename__ = "tpsc_catalog" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True) + name: Mapped[str] = mapped_column(String(200), nullable=False) + provider: Mapped[str | None] = mapped_column(String(200), nullable=True) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + website_url: Mapped[str | None] = mapped_column(Text, nullable=True) + # Pricing: free | paid | freemium | usage_based | unknown + pricing_model: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown") + # GDPR maturity (CNIL/IAPP CMMI-aligned): + # unknown | non_compliant | initial | developing | defined | managed | certified + gdpr_maturity: Mapped[str] = mapped_column(String(20), nullable=False, server_default="unknown", index=True) + gdpr_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + dpa_available: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + tos_url: Mapped[str | None] = mapped_column(Text, nullable=True) + privacy_policy_url: Mapped[str | None] = mapped_column(Text, nullable=True) + data_processing_regions: Mapped[list | None] = mapped_column(JSON, nullable=True) + data_retention_notes: Mapped[str | None] = mapped_column(Text, nullable=True) + # status: active | deprecated + status: Mapped[str] = mapped_column(String(20), nullable=False, server_default="active") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="catalog_entry") + + +class TPSCSnapshot(Base): + __tablename__ = "tpsc_snapshots" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + repo_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True, index=True) + snapshot_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + source_file: Mapped[str | None] = mapped_column(String(200), nullable=True) + entry_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0") + + entries: Mapped[list["TPSCEntry"]] = relationship("TPSCEntry", back_populates="snapshot", cascade="all, delete-orphan") + + +class TPSCEntry(Base): + __tablename__ = "tpsc_entries" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + snapshot_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tpsc_snapshots.id", ondelete="CASCADE"), nullable=False, index=True) + catalog_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tpsc_catalog.id", ondelete="SET NULL"), nullable=True) + service_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + purpose: Mapped[str | None] = mapped_column(Text, nullable=True) + # auth_type: api_key | oauth | cli | none | unknown + auth_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + endpoint_override: Mapped[str | None] = mapped_column(Text, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + snapshot: Mapped["TPSCSnapshot"] = relationship("TPSCSnapshot", back_populates="entries") + catalog_entry: Mapped["TPSCCatalog | None"] = relationship("TPSCCatalog", back_populates="entries") diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/work_record_identifier_alias.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/work_record_identifier_alias.py new file mode 100644 index 00000000..23f389cb --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/work_record_identifier_alias.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, String, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from api.models.base import Base, TimestampMixin, new_uuid + + +class WorkRecordIdentifierAlias(Base, TimestampMixin): + """Durable provenance for a canonical work-record UUID replacement.""" + + __tablename__ = "work_record_identifier_aliases" + __table_args__ = ( + CheckConstraint( + "record_kind IN ('workplan', 'task')", + name="ck_work_record_identifier_aliases_kind", + ), + CheckConstraint( + "migration_status IN ('prepared', 'applied', 'reversed')", + name="ck_work_record_identifier_aliases_status", + ), + UniqueConstraint("old_id", name="uq_work_record_identifier_aliases_old_id"), + UniqueConstraint("new_id", name="uq_work_record_identifier_aliases_new_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + old_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + new_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + record_kind: Mapped[str] = mapped_column(String(20), nullable=False) + record_id: Mapped[str] = mapped_column(String(160), nullable=False, index=True) + repo_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + namespace: Mapped[str] = mapped_column(String(64), nullable=False) + plan_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + migration_status: Mapped[str] = mapped_column( + String(20), nullable=False, default="prepared", server_default="prepared" + ) + applied_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + reversed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan.py new file mode 100644 index 00000000..8006b280 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan.py @@ -0,0 +1,89 @@ +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class Workplan(Base, TimestampMixin): + __tablename__ = "workplans" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + topic_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True + ) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="active", server_default="active" + ) + owner: Mapped[str | None] = mapped_column(String(100), nullable=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True) + planning_priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + planning_order: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + execution_state: Mapped[str] = mapped_column( + String(20), nullable=False, default="manual", server_default="manual", index=True + ) + launch_mode: Mapped[str] = mapped_column( + String(20), nullable=False, default="manual", server_default="manual", index=True + ) + concurrency_mode: Mapped[str] = mapped_column( + String(20), nullable=False, default="sequential", server_default="sequential", index=True + ) + queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + flavor_promotion_reason: Mapped[str | None] = mapped_column(String(32), nullable=True) + flavor_promoted_from: Mapped[str | None] = mapped_column(String(32), nullable=True) + + repo_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + repo_goal_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("repo_goals.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + backing_filename: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Projection provenance and retirement (ADR-012 decisions 2 and 7). + # A retired record is one the forge no longer derives. It is not deleted: + # hub-native records reference it with RESTRICT and must survive. It is not + # `archived` either — that says the owner closed the work, which is a + # different fact. + projection_retired_at: Mapped["datetime | None"] = mapped_column( # noqa: F821 + DateTime(timezone=True), nullable=True, index=True + ) + projection_retired_reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + derived_from_commit: Mapped[str | None] = mapped_column(String(40), nullable=True) + backing_relative_path: Mapped[str | None] = mapped_column(Text, nullable=True) + backing_archived: Mapped[bool | None] = mapped_column(nullable=True) + backing_synced_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + topic: Mapped["Topic | None"] = relationship("Topic", back_populates="workplans") # noqa: F821 + repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821 + repo_goal: Mapped["RepoGoal"] = relationship("RepoGoal", back_populates="workplans", lazy="selectin") # noqa: F821 + tasks: Mapped[list["Task"]] = relationship( # noqa: F821 + "Task", back_populates="workplan", lazy="selectin" + ) + decisions: Mapped[list["Decision"]] = relationship( # noqa: F821 + "Decision", back_populates="workplan", lazy="selectin" + ) + progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821 + "ProgressEvent", back_populates="workplan", lazy="selectin" + ) + launch_requests: Mapped[list["WorkplanLaunchRequest"]] = relationship( # noqa: F821 + "WorkplanLaunchRequest", back_populates="workplan", lazy="selectin" + ) \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan_dependency.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan_dependency.py new file mode 100644 index 00000000..fdc5a894 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan_dependency.py @@ -0,0 +1,109 @@ +import uuid + +from sqlalchemy import CheckConstraint, ForeignKey, Index, String, Text, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class WorkplanDependency(Base, TimestampMixin): + """Directed dependency edge: `from_workplan` depends on a workplan or task. + + Semantics: the target must reach a satisfactory state before `from_workplan` + can fully proceed. Hard deletes are intentional — + removing an edge removes a constraint, not information. + """ + + __tablename__ = "workplan_dependencies" + __table_args__ = ( + CheckConstraint( + "(to_workplan_id IS NOT NULL AND to_task_id IS NULL) " + "OR (to_workplan_id IS NULL AND to_task_id IS NOT NULL)", + name="ck_wp_dep_exactly_one_target", + ), + Index( + "uq_wp_dep_workplan_target", + "from_workplan_id", + "to_workplan_id", + "relationship_type", + unique=True, + postgresql_where=text("to_workplan_id IS NOT NULL AND from_task_id IS NULL"), + ), + Index( + "uq_wp_dep_task_target", + "from_workplan_id", + "to_task_id", + "relationship_type", + unique=True, + postgresql_where=text("to_task_id IS NOT NULL AND from_task_id IS NULL"), + ), + # Task-block `depends_on` edges (CUST-WP-0074): the waiting task is the + # from side, so two tasks in one workplan may wait on the same target. + Index( + "uq_wp_dep_from_task_workplan_target", + "from_task_id", + "to_workplan_id", + "relationship_type", + unique=True, + postgresql_where=text("to_workplan_id IS NOT NULL AND from_task_id IS NOT NULL"), + ), + Index( + "uq_wp_dep_from_task_task_target", + "from_task_id", + "to_task_id", + "relationship_type", + unique=True, + postgresql_where=text("to_task_id IS NOT NULL AND from_task_id IS NOT NULL"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + from_workplan_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=False, + index=True, + ) + # Set when the edge originates from a task block rather than workplan + # frontmatter; always belongs to `from_workplan_id`. + from_task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=True, + index=True, + ) + to_workplan_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=True, + index=True, + ) + to_task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=True, + index=True, + ) + relationship_type: Mapped[str] = mapped_column( + String(40), nullable=False, default="blocks", server_default="blocks", index=True + ) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + # Source-edge ownership (STATE-WP-0094-T05, docs/forge-dependency-edge-receiving.md). + # ``forge:`` when normal Forge receiving derived this edge from + # that repository's files; NULL for externally authored relations (API, + # MCP, consistency fix path), which receiving never removes. + source_owner: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True) + # Immutable source commit that first introduced the source-owned edge. + source_commit: Mapped[str | None] = mapped_column(String(64), nullable=True) + + from_workplan: Mapped["Workplan"] = relationship( # noqa: F821 + "Workplan", foreign_keys=[from_workplan_id] + ) + to_workplan: Mapped["Workplan | None"] = relationship( # noqa: F821 + "Workplan", foreign_keys=[to_workplan_id] + ) + from_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[from_task_id]) # noqa: F821 + to_task: Mapped["Task | None"] = relationship("Task", foreign_keys=[to_task_id]) # noqa: F821 diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan_launch_request.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan_launch_request.py new file mode 100644 index 00000000..284bd375 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workplan_launch_request.py @@ -0,0 +1,39 @@ +import uuid + +from sqlalchemy import Boolean, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class WorkplanLaunchRequest(Base, TimestampMixin): + __tablename__ = "workplan_launch_requests" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + workplan_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workplans.id", ondelete="CASCADE", onupdate="CASCADE"), + nullable=False, + index=True, + ) + requested_by: Mapped[str] = mapped_column(String(100), nullable=False, default="dashboard") + requested_actor: Mapped[str | None] = mapped_column(String(100), nullable=True) + launch_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True) + concurrency_mode: Mapped[str] = mapped_column(String(20), nullable=False, default="sequential", index=True) + priority: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True) + repo_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("managed_repos.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + branch_preference: Mapped[str | None] = mapped_column(Text, nullable=True) + immediate_pickup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + status: Mapped[str] = mapped_column(String(20), nullable=False, default="requested", server_default="requested", index=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + request_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + + workplan: Mapped["Workplan"] = relationship("Workplan", back_populates="launch_requests") # noqa: F821 diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workstream.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workstream.py new file mode 100644 index 00000000..fb268643 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workstream.py @@ -0,0 +1,6 @@ +"""Backward-compatibility shim — prefer ``api.models.workplan``.""" +from api.models.workplan import Workplan + +Workstream = Workplan + +__all__ = ["Workstream", "Workplan"] \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workstream_dependency.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workstream_dependency.py new file mode 100644 index 00000000..ac16f6e0 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/workstream_dependency.py @@ -0,0 +1,6 @@ +"""Backward-compatibility shim — prefer ``api.models.workplan_dependency``.""" +from api.models.workplan_dependency import WorkplanDependency + +WorkstreamDependency = WorkplanDependency + +__all__ = ["WorkstreamDependency", "WorkplanDependency"] \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/write_idempotency_key.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/write_idempotency_key.py new file mode 100644 index 00000000..3ebc21f3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/models/write_idempotency_key.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from api.models.base import Base, new_uuid + + +class WriteIdempotencyKey(Base): + __tablename__ = "write_idempotency_keys" + __table_args__ = ( + UniqueConstraint("key", name="uq_write_idempotency_keys_key"), + ) + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=new_uuid) + key: Mapped[str] = mapped_column(String(200), nullable=False, index=True) + method: Mapped[str] = mapped_column(String(10), nullable=False) + path: Mapped[str] = mapped_column(Text, nullable=False) + route_class: Mapped[str] = mapped_column(String(30), nullable=False) + request_hash: Mapped[str] = mapped_column(String(64), nullable=False) + response_status: Mapped[int] = mapped_column(Integer, nullable=False) + response_body: Mapped[Any] = mapped_column(JSONB, nullable=True) + source_host: Mapped[str | None] = mapped_column(String(200), nullable=True) + source_agent: Mapped[str | None] = mapped_column(String(100), nullable=True) + first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/__init__.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/capability_requests.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/capability_requests.py new file mode 100644 index 00000000..74b13ba9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/capability_requests.py @@ -0,0 +1,532 @@ +import re +import uuid +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict +from api.models.agent_message import BROADCAST, AgentMessage +from api.services.message_receipts import NEWS_DEFAULT_TTL, utcnow +from api.models.capability_catalog import CapabilityCatalog +from api.models.capability_request import CapabilityRequest +from api.models.domain import Domain +from api.models.task import Task +from api.services.suggestion_relevance import bump_matching_for_capability_request +from api.schemas.capability_request import ( + CapabilityRequestAccept, + CapabilityRequestCreate, + CapabilityRequestDispute, + CapabilityRequestPatch, + CapabilityRequestRead, + CapabilityRequestReroute, + CapabilityRequestStatusPatch, + CatalogCreate, + CatalogPatch, + CatalogRead, +) +from hub_core.routers.capabilities import ( + create_capability_request_read_router, + create_capability_request_write_router, +) +from api.services.repository_aliases import resolve_repository_slug + + +# --------------------------------------------------------------------------- +# Write-router callbacks +# --------------------------------------------------------------------------- + +async def _route_capability( + session: AsyncSession, + body: CapabilityRequestCreate, +) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]: + fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability_match( + session, + body.capability_type, + body.title, + body.description or "", + ) + return fulfilling_domain_id, catalog_entry_id, routing_note + + +def _build_capability_request( + body: CapabilityRequestCreate, + requesting_domain: Domain, + fulfilling_domain_id: uuid.UUID | None, + catalog_entry_id: uuid.UUID | None, + routing_note: str | None, +) -> CapabilityRequest: + return CapabilityRequest( + title=body.title, + description=body.description, + capability_type=body.capability_type, + priority=body.priority, + requesting_domain_id=requesting_domain.id, + requesting_agent=body.requesting_agent, + requesting_workplan_id=body.requesting_workplan_id, + blocking_task_id=body.blocking_task_id, + fulfilling_domain_id=fulfilling_domain_id, + catalog_entry_id=catalog_entry_id, + routing_note=routing_note, + ) + + +async def _on_capability_request_persisted( + session: AsyncSession, + req: CapabilityRequest, + body: CapabilityRequestCreate, +) -> None: + await session.flush() + await bump_matching_for_capability_request( + session, + request_id=req.id, + title=body.title, + description=body.description or "", + capability_type=body.capability_type, + catalog_entry_id=req.catalog_entry_id, + ) + await _notify_on_create(session, req, body) + + +async def _notify_on_create( + session: AsyncSession, + req: CapabilityRequest, + body: CapabilityRequestCreate, +) -> None: + await session.flush() + + if req.fulfilling_domain_id: + ful_domain = await session.get(Domain, req.fulfilling_domain_id) + to_agent = ful_domain.slug if ful_domain else "broadcast" + else: + to_agent = "broadcast" + + _add_notification( + session, + from_agent="system", + to_agent=to_agent, + subject=f"[capability-request] {body.title}", + body=( + f"New capability request from **{body.requesting_agent}** " + f"({body.requesting_domain}):\n\n" + f"**Type:** {body.capability_type}\n" + f"**Priority:** {body.priority}\n\n" + f"{body.description or '(no description)'}" + ), + ) + + +def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None: + req.fulfilling_workplan_id = body.fulfilling_workplan_id + + +async def _notify_on_accept( + session: AsyncSession, + req: CapabilityRequest, + body: CapabilityRequestAccept, +) -> None: + _add_notification( + session, + from_agent=body.fulfilling_agent, + to_agent=req.requesting_agent, + subject=f"[capability-accepted] {req.title}", + body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.", + ) + + +async def _on_status_change( + session: AsyncSession, + req: CapabilityRequest, + body: CapabilityRequestStatusPatch, + now: datetime, +) -> None: + if body.status == "completed": + if req.blocking_task_id: + task = await session.get(Task, req.blocking_task_id) + if task and task.status == "wait": + task.status = "todo" + task.blocking_reason = None + + _add_notification( + session, + from_agent="system", + to_agent=req.requesting_agent, + subject=f"[capability-completed] {req.title}", + body=( + f"Capability request **{req.title}** has been completed.\n\n" + f"{body.note or ''}" + ), + ) + elif body.status == "ready_for_review": + _add_notification( + session, + from_agent=req.fulfilling_agent or "system", + to_agent=req.requesting_agent, + subject=f"[capability-ready] {req.title} -- please review", + body=( + f"Capability **{req.title}** is ready for your review and optimization.\n\n" + f"{body.note or ''}" + ), + ) + elif body.status == "rejected": + _add_notification( + session, + from_agent=req.fulfilling_agent or "system", + to_agent=req.requesting_agent, + subject=f"[capability-rejected] {req.title}", + body=( + f"Capability request **{req.title}** has been rejected.\n\n" + f"**Reason:** {body.note or '(no reason given)'}" + ), + ) + elif body.status == "in_progress": + _add_notification( + session, + from_agent=req.fulfilling_agent or "system", + to_agent=req.requesting_agent, + subject=f"[capability-in-progress] {req.title}", + body=f"Work on capability **{req.title}** is now in progress.", + ) + + +async def _apply_capability_patch( + session: AsyncSession, + req: CapabilityRequest, + body: CapabilityRequestPatch, +) -> bool: + corrections: list[str] = [] + + if body.catalog_entry_id is not None: + old_entry_id = req.catalog_entry_id + entry = await session.get(CapabilityCatalog, body.catalog_entry_id) + if entry is None: + raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found") + req.catalog_entry_id = entry.id + req.fulfilling_domain_id = entry.domain_id + corrections.append( + f"catalog_entry: {old_entry_id} → {entry.id} ({entry.title}); " + f"fulfilling_domain re-derived → {entry.domain_id}" + ) + + if body.priority is not None: + req.priority = body.priority + corrections.append(f"priority → {body.priority}") + + if body.blocking_task_id is not None: + req.blocking_task_id = body.blocking_task_id + corrections.append(f"blocking_task_id → {body.blocking_task_id}") + + if body.fulfilling_workplan_id is not None: + req.fulfilling_workplan_id = body.fulfilling_workplan_id + corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}") + + if not corrections: + return False + + correction_note = "hub correction: " + "; ".join(corrections) + req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note + return True + + +async def _notify_on_dispute( + session: AsyncSession, + req: CapabilityRequest, + body: CapabilityRequestDispute, + now: datetime, +) -> None: + dispute_entry = ( + f"disputed by {body.disputed_by}: {body.reason}" + + (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "") + ) + req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry + + _add_notification( + session, + from_agent=body.disputed_by, + to_agent="custodian", + subject=f"[routing-disputed] {req.title}", + body=( + f"**{body.disputed_by}** has disputed the routing of capability request " + f"**{req.title}**.\n\n" + f"**Reason:** {body.reason}\n" + + (f"**Suggested domain:** {body.suggested_domain}\n" if body.suggested_domain else "") + + f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}" + ), + ) + if req.fulfilling_domain_slug: + _add_notification( + session, + from_agent=body.disputed_by, + to_agent=req.fulfilling_domain_slug, + subject=f"[routing-disputed] {req.title}", + body=( + f"The capability request **{req.title}** routed to your domain has been disputed " + f"by **{body.disputed_by}**.\n\n" + f"**Reason:** {body.reason}\n" + + (f"**Suggested domain:** {body.suggested_domain}" if body.suggested_domain else "") + ), + ) + + +async def _notify_on_reroute( + session: AsyncSession, + req: CapabilityRequest, + body: CapabilityRequestReroute, + new_domain_slug: str, +) -> None: + _add_notification( + session, + from_agent=body.rerouted_by, + to_agent=req.requesting_agent, + subject=f"[re-routed] {req.title}", + body=( + f"Capability request **{req.title}** has been re-routed to **{new_domain_slug}**.\n\n" + f"**Note:** {body.note}" + ), + ) + _add_notification( + session, + from_agent=body.rerouted_by, + to_agent=new_domain_slug, + subject=f"[capability-request] {req.title}", + body=( + f"Capability request **{req.title}** has been re-routed to your domain.\n\n" + f"**From:** {req.requesting_agent} ({req.requesting_domain_slug})\n" + f"**Type:** {req.capability_type}\n" + f"**Priority:** {req.priority}\n\n" + f"{req.description or '(no description)'}" + ), + ) + + +# --------------------------------------------------------------------------- +# Routing algorithm +# --------------------------------------------------------------------------- + +async def _route_capability_match( + session: AsyncSession, + capability_type: str, + title: str, + description: str, +) -> tuple[uuid.UUID | None, uuid.UUID | None, str]: + """Find the best-matching catalog entry for a capability request. + + Returns (domain_id, catalog_entry_id, routing_note). + """ + q = select(CapabilityCatalog).where( + CapabilityCatalog.capability_type == capability_type, + CapabilityCatalog.status == "active", + ) + entries = list((await session.execute(q)).scalars().all()) + + if not entries: + return None, None, f"no active catalog entries for type '{capability_type}' — broadcast" + + if len(entries) == 1: + entry = entries[0] + return entry.domain_id, entry.id, f"single match: '{entry.title}' (domain={entry.domain_id})" + + combined = f"{title} {description or ''}".lower() + scored: list[tuple[int, CapabilityCatalog]] = [] + for entry in entries: + keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3] + score = sum( + 1 for kw in keywords + if re.search(r"\b" + re.escape(kw.lower()) + r"\b", combined) + ) + scored.append((score, entry)) + scored.sort(key=lambda item: -item[0]) + + best_score, best = scored[0] + if best_score == 0: + return None, None, ( + f"no keyword overlap for type '{capability_type}' among " + f"{len(entries)} entries — broadcast" + ) + if len(scored) >= 2 and scored[1][0] == best_score: + return None, None, ( + f"ambiguous routing: '{scored[0][1].title}' and '{scored[1][1].title}' " + f"both scored {best_score} — broadcast" + ) + + return best.domain_id, best.id, ( + f"matched '{best.title}' (score={best_score}, " + f"keywords matched from: {title!r})" + ) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _add_notification( + session: AsyncSession, + from_agent: str, + to_agent: str, + subject: str, + body: str, +) -> None: + msg = AgentMessage( + from_agent=from_agent, + to_agent=to_agent, + subject=subject, + body=body, + ) + if to_agent == BROADCAST: + # STATE-WP-0093: system broadcasts are news (seen once per reader). + msg.kind = "news" + msg.expires_at = utcnow() + NEWS_DEFAULT_TTL + session.add(msg) + + +def _check_transition(current: str, target: str) -> None: + can_reach, failures, flow_result = evaluate_transition( + "capability_request", + current, + target, + ) + if not can_reach: + raise HTTPException( + status_code=422, + detail={ + "message": f"Cannot transition from '{current}' to '{target}'.", + "current_workstation": current, + "target_workstation": target, + "blocking_assertions": [ + assertion_result_to_dict(item) for item in failures + ], + "flow_result": flow_result_to_dict(flow_result), + }, + ) + + +router = APIRouter(tags=["capability-requests"]) + + +async def _catalog_domain(slug: str, session: AsyncSession) -> Domain: + domain = ( + await session.execute(select(Domain).where(Domain.slug == slug)) + ).scalar_one_or_none() + if domain is None: + raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found") + return domain + + +@router.post( + "/capability-catalog/", + response_model=CatalogRead, + status_code=status.HTTP_201_CREATED, +) +async def create_catalog_entry( + body: CatalogCreate, + session: AsyncSession = Depends(get_session), +) -> CapabilityCatalog: + domain = await _catalog_domain(body.domain, session) + repo_id = None + if body.repo_slug: + repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id + entry = CapabilityCatalog( + domain_id=domain.id, + repo_id=repo_id, + capability_type=body.capability_type, + title=body.title, + description=body.description, + keywords=body.keywords, + ) + session.add(entry) + try: + await session.commit() + except Exception: + await session.rollback() + raise HTTPException( + status_code=409, + detail=( + f"Catalog entry '{body.title}' for type '{body.capability_type}' " + f"already exists in domain '{body.domain}'" + ), + ) + await session.refresh(entry) + return entry + + +@router.get("/capability-catalog/", response_model=list[CatalogRead]) +async def list_catalog( + domain: str | None = Query(None), + capability_type: str | None = Query(None), + status_filter: str | None = Query(None, alias="status"), + repo_slug: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[CapabilityCatalog]: + query = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc()) + if domain: + query = query.where( + CapabilityCatalog.domain_id == (await _catalog_domain(domain, session)).id + ) + if capability_type: + query = query.where(CapabilityCatalog.capability_type == capability_type) + if repo_slug: + query = query.where( + CapabilityCatalog.repo_id + == (await resolve_repository_slug(session, repo_slug)).repo.id + ) + if status_filter and status_filter != "all": + query = query.where(CapabilityCatalog.status == status_filter) + elif not status_filter: + query = query.where(CapabilityCatalog.status == "active") + return list((await session.execute(query)).scalars().all()) + + +@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead) +async def patch_catalog_entry( + entry_id: uuid.UUID, + body: CatalogPatch, + session: AsyncSession = Depends(get_session), +) -> CapabilityCatalog: + entry = await session.get(CapabilityCatalog, entry_id) + if entry is None: + raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found") + if body.repo_slug is not None: + entry.repo_id = (await resolve_repository_slug(session, body.repo_slug)).repo.id + for field in ("description", "keywords", "status"): + value = getattr(body, field) + if value is not None: + setattr(entry, field, value) + await session.commit() + await session.refresh(entry) + return entry + + +router.include_router( + create_capability_request_read_router( + get_session, + domain_model=Domain, + request_model=CapabilityRequest, + request_read_schema=CapabilityRequestRead, + ) +) +router.include_router( + create_capability_request_write_router( + get_session, + domain_model=Domain, + catalog_model=CapabilityCatalog, + request_model=CapabilityRequest, + request_create_schema=CapabilityRequestCreate, + request_accept_schema=CapabilityRequestAccept, + request_patch_schema=CapabilityRequestPatch, + request_status_patch_schema=CapabilityRequestStatusPatch, + request_dispute_schema=CapabilityRequestDispute, + request_reroute_schema=CapabilityRequestReroute, + request_read_schema=CapabilityRequestRead, + route_request=_route_capability, + build_request=_build_capability_request, + on_request_persisted=_on_capability_request_persisted, + check_transition=_check_transition, + apply_accept_fields=_apply_accept_fields, + after_accept=_notify_on_accept, + after_status_change=_on_status_change, + apply_patch=_apply_capability_patch, + after_dispute=_notify_on_dispute, + after_reroute=_notify_on_reroute, + ) +) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/consistency_sweep.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/consistency_sweep.py new file mode 100644 index 00000000..bd1d7e32 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/consistency_sweep.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.schemas.consistency_sweep import ( + ConsistencySweepRemoteAllGenerate, + ConsistencySweepRemoteAllRun, +) +from api.services.consistency_sweep import run_remote_all_sweep + +router = APIRouter(prefix="/consistency/sweep", tags=["consistency"]) + + +@router.post( + "/remote-all", + response_model=ConsistencySweepRemoteAllRun, + status_code=status.HTTP_201_CREATED, +) +async def sweep_remote_all( + body: ConsistencySweepRemoteAllGenerate, + session: AsyncSession = Depends(get_session), +) -> ConsistencySweepRemoteAllRun: + try: + return await run_remote_all_sweep( + session, + max_seconds=body.max_seconds, + source=body.source, + ) + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=500, + detail=f"Consistency sweep returned invalid JSON: {exc}", + ) from exc \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/contributions.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/contributions.py new file mode 100644 index 00000000..bd294226 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/contributions.py @@ -0,0 +1,137 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.flow_defs import assertion_result_to_dict, evaluate_transition, flow_result_to_dict +from api.models.contribution import Contribution, ContributionStatus, ContributionType +from api.schemas.contribution import ContributionCreate, ContributionRead, ContributionStatusPatch + +router = APIRouter(prefix="/contributions", tags=["contributions"]) + +@router.get("/", response_model=list[ContributionRead]) +async def list_contributions( + type: ContributionType | None = Query(None), + status: ContributionStatus | None = Query(None), + target_repo: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[Contribution]: + q = select(Contribution).order_by(Contribution.created_at.desc()) + if type is not None: + q = q.where(Contribution.type == type) + if status is not None: + q = q.where(Contribution.status == status) + if target_repo: + q = q.where(Contribution.target_repo == target_repo) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=ContributionRead, status_code=status.HTTP_201_CREATED) +async def create_contribution( + body: ContributionCreate, + session: AsyncSession = Depends(get_session), +) -> Contribution: + contrib = Contribution( + type=body.type, + target_org=body.target_org, + target_repo=body.target_repo, + slug=body.slug, + title=body.title, + body_path=body.body_path, + related_topic_id=body.related_topic_id, + related_workplan_id=body.related_workplan_id, + notes=body.notes, + status=ContributionStatus.draft, + ) + session.add(contrib) + await session.commit() + await session.refresh(contrib) + return contrib + + +@router.get("/{contribution_id}", response_model=ContributionRead) +async def get_contribution( + contribution_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Contribution: + return await _get_or_404(contribution_id, session) + + +@router.patch("/{contribution_id}/status", response_model=ContributionRead) +async def patch_contribution_status( + contribution_id: uuid.UUID, + body: ContributionStatusPatch, + session: AsyncSession = Depends(get_session), +) -> Contribution: + contrib = await _get_or_404(contribution_id, session) + current = _status_value(contrib.status) + target = _status_value(body.status) + can_reach, failures, flow_result = evaluate_transition( + "contribution", + current, + target, + ) + if not can_reach: + raise HTTPException( + status_code=422, + detail={ + "message": f"Cannot transition from '{current}' to '{target}'.", + "current_workstation": current, + "target_workstation": target, + "blocking_assertions": [ + assertion_result_to_dict(item) for item in failures + ], + "flow_result": flow_result_to_dict(flow_result), + }, + ) + contrib.status = body.status + if body.notes: + contrib.notes = body.notes + now = datetime.now(tz=timezone.utc) + if body.status == ContributionStatus.submitted: + contrib.submitted_at = now + elif body.status in ( + ContributionStatus.accepted, ContributionStatus.rejected, + ContributionStatus.merged, ContributionStatus.withdrawn, + ): + contrib.resolved_at = now + await session.commit() + await session.refresh(contrib) + return contrib + + +@router.delete("/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT) +async def withdraw_contribution( + contribution_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Soft-delete: sets status to 'withdrawn'.""" + contrib = await _get_or_404(contribution_id, session) + if contrib.status == ContributionStatus.withdrawn: + return # idempotent + if contrib.status in (ContributionStatus.merged, ContributionStatus.rejected): + raise HTTPException( + status_code=409, + detail=f"Cannot withdraw a contribution with status '{contrib.status}'.", + ) + contrib.status = ContributionStatus.withdrawn + contrib.resolved_at = datetime.now(tz=timezone.utc) + await session.commit() + + +async def _get_or_404(contribution_id: uuid.UUID, session: AsyncSession) -> Contribution: + result = await session.execute( + select(Contribution).where(Contribution.id == contribution_id) + ) + contrib = result.scalar_one_or_none() + if contrib is None: + raise HTTPException(status_code=404, detail=f"Contribution '{contribution_id}' not found") + return contrib + + +def _status_value(status: ContributionStatus | str) -> str: + return status.value if isinstance(status, ContributionStatus) else str(status) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/decisions.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/decisions.py new file mode 100644 index 00000000..069e9a01 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/decisions.py @@ -0,0 +1,263 @@ +import asyncio +import logging +import uuid +from datetime import datetime, timezone +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.events import EventEnvelope, publish_event +from api.models.decision import Decision, DecisionStatus, DecisionType +from api.models.progress_event import ProgressEvent +from api.models.review_contract import ReviewContract +from api.schemas.decision import DecisionCreate, DecisionRead, DecisionResolve, DecisionUpdate +from api.services.legacy_compat import meter_legacy_body_from_model, meter_legacy_query_param +from api.services.review_contracts import aggregate + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/decisions", tags=["decisions"]) + +_FINANCIAL_LEGAL_KEYWORDS = ( + "financ", "legal", "payment", "purchas", "contract", "commit", + "obligation", "external representation", +) + + +def _needs_escalation(body: DecisionCreate) -> str | None: + if body.decision_type != DecisionType.pending: + return None + text = f"{body.title} {body.description or ''}".lower() + for kw in _FINANCIAL_LEGAL_KEYWORDS: + if kw in text: + return ( + "Auto-escalated per constitution §4: this pending decision touches " + "financial or legal territory and requires explicit human approval before action." + ) + return None + + +@router.get("/", response_model=list[DecisionRead]) +async def list_decisions( + request: Request, + response: Response, + topic_id: uuid.UUID | None = None, + workplan_id: uuid.UUID | None = None, + workstream_id: uuid.UUID | None = None, + status: DecisionStatus | None = None, + decision_type: DecisionType | None = None, + session: AsyncSession = Depends(get_session), +) -> list[Decision]: + if workstream_id is not None and workplan_id is None: + await meter_legacy_query_param( + session=session, + request=request, + response=response, + method="GET", + route="/decisions/", + replacement_ref="/decisions/?workplan_id=", + ) + q = select(Decision) + if topic_id: + q = q.where(Decision.topic_id == topic_id) + scope_id = workplan_id or workstream_id + if scope_id: + q = q.where(Decision.workplan_id == scope_id) + if status: + q = q.where(Decision.status == status) + if decision_type: + q = q.where(Decision.decision_type == decision_type) + q = q.order_by(Decision.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=DecisionRead, status_code=status.HTTP_201_CREATED) +async def create_decision( + request: Request, + response: Response, + body: DecisionCreate, + session: AsyncSession = Depends(get_session), +) -> Decision: + await meter_legacy_body_from_model( + body, + session=session, + request=request, + response=response, + method="POST", + route="/decisions/", + replacement_ref="POST /decisions/ with workplan_id", + ) + data = body.model_dump() + note = _needs_escalation(body) + if note: + data["escalation_note"] = note + data["status"] = DecisionStatus.escalated + decision = Decision(**data) + session.add(decision) + await session.commit() + await session.refresh(decision) + return decision + + +@router.get("/{decision_id}", response_model=DecisionRead) +async def get_decision( + decision_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Decision: + decision = await session.get(Decision, decision_id) + if decision is None: + raise HTTPException(status_code=404, detail="Decision not found") + return decision + + +@router.patch("/{decision_id}", response_model=DecisionRead) +async def update_decision( + decision_id: uuid.UUID, + body: DecisionUpdate, + session: AsyncSession = Depends(get_session), +) -> Decision: + decision = await session.get(Decision, decision_id) + if decision is None: + raise HTTPException(status_code=404, detail="Decision not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(decision, field, value) + await session.commit() + await session.refresh(decision) + return decision + + +@router.delete("/{decision_id}", response_model=DecisionRead) +async def supersede_decision( + decision_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Decision: + decision = await session.get(Decision, decision_id) + if decision is None: + raise HTTPException(status_code=404, detail="Decision not found") + decision.status = DecisionStatus.superseded + await session.commit() + await session.refresh(decision) + return decision + + +@router.post("/{decision_id}/resolve", response_model=DecisionRead) +async def resolve_decision_action( + decision_id: uuid.UUID, + body: DecisionResolve, + session: AsyncSession = Depends(get_session), +) -> Decision: + decision = await session.get(Decision, decision_id) + if decision is None: + raise HTTPException(status_code=404, detail="Decision not found") + if decision.status == DecisionStatus.resolved: + raise HTTPException(status_code=409, detail="Decision already resolved") + + review_rows = await session.execute( + select(ReviewContract).where( + ReviewContract.decision_id == decision.id, + ReviewContract.active.is_(True), + ReviewContract.required_for_decision.is_(True), + ) + ) + for contract in review_rows.scalars(): + review_state = await aggregate(session, contract) + if not review_state.satisfied: + raise HTTPException( + status_code=409, + detail={ + "message": "required multi-owner review is not satisfied", + "contract_key": contract.contract_key, + "contract_digest": contract.contract_digest, + }, + ) + + decision.status = DecisionStatus.resolved + decision.decision_type = DecisionType.made + decision.rationale = body.rationale + decision.decided_by = body.decided_by + decision.decided_at = datetime.now(tz=timezone.utc) + await session.commit() + await session.refresh(decision) + + event = ProgressEvent( + topic_id=decision.topic_id, + workplan_id=decision.workplan_id, + decision_id=decision.id, + event_type="decision_resolved", + summary=f"Decision resolved: {decision.title}", + author=body.decided_by, + detail={"rationale": body.rationale}, + ) + session.add(event) + await session.commit() + + if body.write_log: + await _write_project_log(decision, body.rationale, body.decided_by, session) + + subject = "org.statehub.decision.resolved" + envelope = EventEnvelope.new( + subject, + attributes={ + "decision_id": str(decision.id), + "title": decision.title, + "topic_id": str(decision.topic_id) if decision.topic_id else None, + "workstream_id": str(decision.workplan_id) if decision.workplan_id else None, + "decided_by": body.decided_by, + "rationale_snippet": (body.rationale or "")[:240], + }, + ) + asyncio.create_task(publish_event(subject, envelope)) + + return decision + + +async def _write_project_log( + decision: Decision, rationale: str, decided_by: str, session: AsyncSession +) -> None: + """Append a DECISIONS.md entry to the registered project directory for this topic.""" + if decision.topic_id is None: + return + + rows = await session.execute( + select(ProgressEvent) + .where(ProgressEvent.topic_id == decision.topic_id) + .where(ProgressEvent.event_type == "milestone") + .order_by(ProgressEvent.created_at.desc()) + ) + project_path: str | None = None + for pe in rows.scalars(): + if pe.summary and "Project registered with State Hub:" in pe.summary: + project_path = (pe.detail or {}).get("project_path") + if project_path: + break + + if not project_path: + logger.warning("write_log requested but no project_path found for topic %s", decision.topic_id) + return + + p = Path(project_path) + if not p.is_dir(): + logger.warning("write_log requested but project_path does not exist: %s", project_path) + return + + now = datetime.now(tz=timezone.utc) + entry = ( + f"\n## {decision.title}\n\n" + f"**Date:** {now.strftime('%Y-%m-%d')} \n" + f"**Decided by:** {decided_by} \n\n" + f"{rationale}\n\n" + f"---\n" + ) + log_file = p / "DECISIONS.md" + if log_file.exists(): + log_file.write_text(log_file.read_text() + entry) + else: + log_file.write_text( + "# Decision Log\n\n" + "_Auto-generated by the Custodian State Hub._\n" + + entry + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/domain_goals.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/domain_goals.py new file mode 100644 index 00000000..2a1fcb3f --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/domain_goals.py @@ -0,0 +1,135 @@ +import asyncio +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.events import EventEnvelope, publish_event +from api.models.domain import Domain +from api.models.domain_goal import DomainGoal, DomainGoalStatus # noqa: F401 (DomainGoalStatus used in activate) +from api.schemas.domain_goal import DomainGoalCreate, DomainGoalRead, DomainGoalUpdate + +router = APIRouter(prefix="/domain-goals", tags=["domain-goals"]) + + +async def _resolve_domain(domain_slug: str, session: AsyncSession) -> Domain: + result = await session.execute(select(Domain).where(Domain.slug == domain_slug)) + domain = result.scalar_one_or_none() + if domain is None: + raise HTTPException(status_code=404, detail=f"Domain '{domain_slug}' not found") + return domain + + +@router.get("/", response_model=list[DomainGoalRead]) +async def list_domain_goals( + domain_slug: str | None = None, + status: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[DomainGoal]: + q = select(DomainGoal) + if domain_slug: + domain = await _resolve_domain(domain_slug, session) + q = q.where(DomainGoal.domain_id == domain.id) + if status: + q = q.where(DomainGoal.status == status) + q = q.order_by(DomainGoal.created_at.desc()) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=DomainGoalRead, status_code=status.HTTP_201_CREATED) +async def create_domain_goal( + body: DomainGoalCreate, + session: AsyncSession = Depends(get_session), +) -> DomainGoal: + if body.status == DomainGoalStatus.active: + # Archive any existing active goal for this domain + existing = await session.execute( + select(DomainGoal).where( + DomainGoal.domain_id == body.domain_id, + DomainGoal.status == DomainGoalStatus.active, + ) + ) + for old in existing.scalars().all(): + old.status = DomainGoalStatus.superseded + + goal = DomainGoal(**body.model_dump()) + session.add(goal) + await session.commit() + await session.refresh(goal) + return goal + + +@router.get("/{goal_id}", response_model=DomainGoalRead) +async def get_domain_goal( + goal_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> DomainGoal: + goal = await session.get(DomainGoal, goal_id) + if goal is None: + raise HTTPException(status_code=404, detail="Domain goal not found") + return goal + + +@router.patch("/{goal_id}", response_model=DomainGoalRead) +async def update_domain_goal( + goal_id: uuid.UUID, + body: DomainGoalUpdate, + session: AsyncSession = Depends(get_session), +) -> DomainGoal: + goal = await session.get(DomainGoal, goal_id) + if goal is None: + raise HTTPException(status_code=404, detail="Domain goal not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(goal, field, value) + await session.commit() + await session.refresh(goal) + return goal + + +@router.post("/{goal_id}/activate", response_model=DomainGoalRead) +async def activate_domain_goal( + goal_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> DomainGoal: + """Set this goal as the active domain goal, superseding any currently active one.""" + goal = await session.get(DomainGoal, goal_id) + if goal is None: + raise HTTPException(status_code=404, detail="Domain goal not found") + was_active = goal.status == DomainGoalStatus.active + + # Supersede any other active goal for this domain + existing = await session.execute( + select(DomainGoal).where( + DomainGoal.domain_id == goal.domain_id, + DomainGoal.status == DomainGoalStatus.active, + DomainGoal.id != goal_id, + ) + ) + superseded_ids: list[str] = [] + for old in existing.scalars().all(): + old.status = DomainGoalStatus.superseded + superseded_ids.append(str(old.id)) + + goal.status = DomainGoalStatus.active + await session.commit() + await session.refresh(goal) + + if not was_active: + domain = await session.get(Domain, goal.domain_id) + subject = "org.statehub.domain.goal.activated" + envelope = EventEnvelope.new( + subject, + attributes={ + "goal_id": str(goal.id), + "domain_id": str(goal.domain_id), + "domain_slug": domain.slug if domain else None, + "title": goal.title, + "superseded_goal_ids": superseded_ids, + }, + ) + asyncio.create_task(publish_event(subject, envelope)) + + return goal diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/domains.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/domains.py new file mode 100644 index 00000000..3f126696 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/domains.py @@ -0,0 +1,102 @@ +from fastapi import HTTPException +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.domain import Domain +from api.models.extension_point import ExtensionPoint +from api.models.managed_repo import ManagedRepo +from api.models.technical_debt import TechnicalDebt +from api.models.topic import Topic +from api.models.workplan import Workplan +from api.schemas.domain import ( + DomainCreate, + DomainDetail, + DomainRead, + DomainRename, + DomainUpdate, + RepoStub, +) +from hub_core.routers.domains import create_domains_router + + +async def _build_domain_detail(domain: Domain, session: AsyncSession) -> DomainDetail: + topic_count_row = await session.execute( + select(func.count()).select_from(Topic).where(Topic.domain_id == domain.id) + ) + topic_count = topic_count_row.scalar_one() + + topic_ids_row = await session.execute(select(Topic.id).where(Topic.domain_id == domain.id)) + topic_ids = [row[0] for row in topic_ids_row.all()] + + workstream_count = 0 + if topic_ids: + workstream_count_row = await session.execute( + select(func.count()).select_from(Workplan) + .where(Workplan.topic_id.in_(topic_ids)) + .where(Workplan.status == "active") + ) + workstream_count = workstream_count_row.scalar_one() + + ep_count_row = await session.execute( + select(func.count()).select_from(ExtensionPoint) + .where(ExtensionPoint.domain_id == domain.id) + ) + td_count_row = await session.execute( + select(func.count()).select_from(TechnicalDebt) + .where(TechnicalDebt.domain_id == domain.id) + ) + repos_row = await session.execute( + select(ManagedRepo) + .where(ManagedRepo.domain_id == domain.id) + .where(ManagedRepo.status == "active") + .order_by(ManagedRepo.name) + ) + repos = list(repos_row.scalars().all()) + + return DomainDetail( + id=domain.id, + slug=domain.slug, + name=domain.name, + description=domain.description, + status=domain.status, + created_at=domain.created_at, + updated_at=domain.updated_at, + topic_count=topic_count, + workstream_count=workstream_count, + ep_count=ep_count_row.scalar_one(), + td_count=td_count_row.scalar_one(), + repos=[RepoStub.model_validate(repo) for repo in repos], + ) + + +async def _reject_archive_with_active_topics(domain: Domain, session: AsyncSession) -> None: + active_topics = await session.execute( + select(func.count()).select_from(Topic) + .where(Topic.domain_id == domain.id) + .where(Topic.status == "active") + ) + if active_topics.scalar_one() > 0: + raise HTTPException( + status_code=409, + detail="Cannot archive domain with active topics. Archive or reassign topics first.", + ) + + +router = create_domains_router( + get_session, + domain_model=Domain, + repo_model=ManagedRepo, + domain_create_schema=DomainCreate, + domain_detail_schema=DomainDetail, + domain_read_schema=DomainRead, + domain_rename_schema=DomainRename, + domain_update_schema=DomainUpdate, + repo_stub_schema=RepoStub, + detail_builder=_build_domain_detail, + before_archive=_reject_archive_with_active_topics, + list_noload_fields=("topics", "repos", "goals"), + include_update_route=False, +) + +__all__ = ["router"] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/execution.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/execution.py new file mode 100644 index 00000000..c55a519a --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/execution.py @@ -0,0 +1,232 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.task import Task, TaskStatus +from api.models.workplan_launch_request import WorkplanLaunchRequest +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.schemas.execution import ( + ExecutionIntentRead, + ExecutionIntentUpdate, + ExecutionSemantics, + LaunchRequestCreate, + LaunchRequestRead, + WorkplanQueueItem, +) +from api.services.execution_queue import ( + ACTIVITY_CORE_RESPONSIBILITIES, + CONCURRENCY_MODES, + EXECUTION_STATES, + EXECUTION_REPLACEMENTS, + LAUNCH_MODES, + STATE_HUB_RESPONSIBILITIES, + queue_sort_key, + workplan_blockers, +) +from api.routers.workstreams import _legacy_key +from api.services.legacy_compat import retire_legacy_route +from api.services.legacy_compat import meter_legacy_query_param +from api.workplan_status import CLOSED_WORKPLAN_STATUSES, normalize_workplan_status + +router = APIRouter(prefix="/execution", tags=["execution"]) + + +@router.get("/semantics", response_model=ExecutionSemantics) +async def execution_semantics() -> ExecutionSemantics: + return ExecutionSemantics( + execution_states=EXECUTION_STATES, + launch_modes=LAUNCH_MODES, + concurrency_modes=CONCURRENCY_MODES, + state_hub_responsibility=STATE_HUB_RESPONSIBILITIES, + activity_core_responsibility=ACTIVITY_CORE_RESPONSIBILITIES, + launch_requests_accepted=False, + replacements=EXECUTION_REPLACEMENTS, + ) + + +async def _update_execution_intent( + *, + workstream_id: uuid.UUID, + body: ExecutionIntentUpdate, + session: AsyncSession, +) -> ExecutionIntentRead: + ws = await session.get(Workplan, workstream_id) + if ws is None: + raise HTTPException(status_code=404, detail="Workplan not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(ws, field, value) + await session.commit() + await session.refresh(ws) + return _intent_read(ws) + + +@router.patch("/workstreams/{workstream_id}/intent", status_code=status.HTTP_410_GONE) +async def update_execution_intent( + request: Request, + response: Response, + workstream_id: uuid.UUID, + body: ExecutionIntentUpdate, + session: AsyncSession = Depends(get_session), +) -> None: + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key=_legacy_key("PATCH", "/execution/workstreams/{workstream_id}/intent"), + replacement_ref="/execution/workplans/{workplan_id}/intent", + detail="Legacy PATCH /execution/workstreams/{workstream_id}/intent retired; " + "use PATCH /execution/workplans/{workplan_id}/intent", + ) + + +@router.patch("/workplans/{workplan_id}/intent", response_model=ExecutionIntentRead) +async def update_workplan_execution_intent( + workplan_id: uuid.UUID, + body: ExecutionIntentUpdate, + session: AsyncSession = Depends(get_session), +) -> ExecutionIntentRead: + return await _update_execution_intent(workstream_id=workplan_id, body=body, session=session) + + +@router.get("/workplan-stack", response_model=list[WorkplanQueueItem]) +async def workplan_stack( + include_manual: bool = Query(True), + include_blocked: bool = Query(True), + session: AsyncSession = Depends(get_session), +) -> list[WorkplanQueueItem]: + result = await session.execute(select(Workplan)) + workstreams = [ + ws for ws in result.scalars().all() + if normalize_workplan_status(ws.status) not in CLOSED_WORKPLAN_STATUSES + ] + ws_by_id = {ws.id: ws for ws in workstreams} + ws_status = {ws.id: normalize_workplan_status(ws.status) for ws in workstreams} + + dep_result = await session.execute(select(WorkplanDependency)) + ws_deps: dict[uuid.UUID, list[uuid.UUID]] = {} + task_deps: dict[uuid.UUID, list[uuid.UUID]] = {} + for dep in dep_result.scalars().all(): + if dep.to_workplan_id is not None: + ws_deps.setdefault(dep.from_workplan_id, []).append(dep.to_workplan_id) + if dep.to_task_id is not None: + task_deps.setdefault(dep.from_workplan_id, []).append(dep.to_task_id) + + task_ids = [task_id for ids in task_deps.values() for task_id in ids] + task_status: dict[uuid.UUID, str] = {} + if task_ids: + task_result = await session.execute(select(Task).where(Task.id.in_(task_ids))) + task_status = {task.id: _task_status(task.status) for task in task_result.scalars().all()} + + items: list[WorkplanQueueItem] = [] + for ws in workstreams: + if not include_manual and ws.execution_state == "manual": + continue + lifecycle_status = normalize_workplan_status(ws.status) + blocked_ws = [ + blocker for blocker in workplan_blockers(ws.id, ws_deps, ws_status) + if blocker in ws_by_id or blocker in ws_status + ] + blocked_tasks = [ + task_id for task_id in task_deps.get(ws.id, []) + if task_status.get(task_id) not in {"done", "cancel"} + ] + eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks + if not include_blocked and not eligible: + continue + sort_key = queue_sort_key(ws, eligible=eligible) + items.append(WorkplanQueueItem( + workplan_id=ws.id, + slug=ws.slug, + title=ws.title, + status=lifecycle_status, + repo_id=ws.repo_id, + planning_priority=ws.planning_priority, + planning_order=ws.planning_order, + execution_state=ws.execution_state, + launch_mode=ws.launch_mode, + concurrency_mode=ws.concurrency_mode, + queue_rank=ws.queue_rank, + execution_group=ws.execution_group, + scheduled_for=ws.scheduled_for, + eligible=eligible, + blocked_by_workplan_ids=blocked_ws, + blocked_by_task_ids=blocked_tasks, + sort_key=sort_key, + )) + return sorted(items, key=lambda item: item.sort_key) + + +@router.post( + "/launch-requests", + status_code=status.HTTP_410_GONE, +) +async def create_launch_request( + request: Request, + response: Response, + body: LaunchRequestCreate, + session: AsyncSession = Depends(get_session), +) -> None: + del body # Request shape stays documented while the retired route returns 410. + await retire_legacy_route( + session=session, + request=request, + response=response, + interface_key="rest_api:POST /execution/launch-requests", + replacement_ref="repo file queue or activity-core ActivityDefinition + ops_run", + detail=( + "State Hub workplan launch requests are retired: no consumer picks up these rows. " + "Queue development work in the authoritative repository file; use an " + "ActivityDefinition and activity-core ops_run only for recurring or operational fires." + ), + ) + + +@router.get("/launch-requests", response_model=list[LaunchRequestRead]) +async def list_launch_requests( + request: Request, + response: Response, + workplan_id: uuid.UUID | None = None, + workstream_id: uuid.UUID | None = None, + request_status: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[WorkplanLaunchRequest]: + if workstream_id is not None and workplan_id is None: + await meter_legacy_query_param( + session=session, + request=request, + response=response, + method="GET", + route="/execution/launch-requests", + replacement_ref="/execution/launch-requests?workplan_id=", + ) + q = select(WorkplanLaunchRequest).order_by(WorkplanLaunchRequest.created_at.desc()) + scope_id = workplan_id or workstream_id + if scope_id: + q = q.where(WorkplanLaunchRequest.workplan_id == scope_id) + if request_status: + q = q.where(WorkplanLaunchRequest.status == request_status) + result = await session.execute(q) + return list(result.scalars().all()) + + +def _intent_read(ws: Workplan) -> ExecutionIntentRead: + return ExecutionIntentRead( + workplan_id=ws.id, + execution_state=ws.execution_state, + launch_mode=ws.launch_mode, + concurrency_mode=ws.concurrency_mode, + queue_rank=ws.queue_rank, + execution_group=ws.execution_group, + scheduled_for=ws.scheduled_for, + ) + + +def _task_status(status_value: TaskStatus | str) -> str: + if hasattr(status_value, "value"): + return status_value.value + return str(status_value or "").strip().lower() diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/extension_points.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/extension_points.py new file mode 100644 index 00000000..ee5883f9 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/extension_points.py @@ -0,0 +1,105 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.domain import Domain +from api.models.extension_point import EPStatus, ExtensionPoint +from api.schemas.extension_point import EPCreate, EPRead, EPUpdate + +router = APIRouter(prefix="/extension-points", tags=["extension-points"]) + + +async def _resolve_domain_id(slug: str, session: AsyncSession) -> uuid.UUID: + """Resolve a domain slug to its UUID, raising 422 if unknown.""" + row = await session.execute( + select(Domain.id).where(Domain.slug == slug, Domain.status == "active") + ) + domain_id = row.scalar_one_or_none() + if domain_id is None: + valid = [r[0] for r in (await session.execute( + select(Domain.slug).where(Domain.status == "active") + )).all()] + raise HTTPException( + status_code=422, + detail=f"Unknown domain '{slug}'. Valid domains: {sorted(valid)}", + ) + return domain_id + + +@router.get("/", response_model=list[EPRead]) +async def list_eps( + domain: str | None = None, + status: EPStatus | None = None, + ep_type: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[ExtensionPoint]: + q = select(ExtensionPoint) + if domain: + domain_id = await _resolve_domain_id(domain, session) + q = q.where(ExtensionPoint.domain_id == domain_id) + if status: + q = q.where(ExtensionPoint.status == status) + if ep_type: + q = q.where(ExtensionPoint.ep_type == ep_type) + q = q.order_by(ExtensionPoint.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=EPRead, status_code=status.HTTP_201_CREATED) +async def create_ep( + body: EPCreate, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + domain_id = await _resolve_domain_id(body.domain, session) + data = body.model_dump(exclude={"domain"}) + data["domain_id"] = domain_id + ep = ExtensionPoint(**data) + session.add(ep) + await session.commit() + await session.refresh(ep) + return ep + + +@router.get("/{ep_id}", response_model=EPRead) +async def get_ep( + ep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + return ep + + +@router.patch("/{ep_id}", response_model=EPRead) +async def update_ep( + ep_id: uuid.UUID, + body: EPUpdate, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(ep, field, value) + await session.commit() + await session.refresh(ep) + return ep + + +@router.delete("/{ep_id}", response_model=EPRead) +async def defer_ep( + ep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> ExtensionPoint: + ep = await session.get(ExtensionPoint, ep_id) + if ep is None: + raise HTTPException(status_code=404, detail="Extension point not found") + ep.status = EPStatus.deferred + await session.commit() + await session.refresh(ep) + return ep diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/fabric.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/fabric.py new file mode 100644 index 00000000..98e704bc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/fabric.py @@ -0,0 +1,514 @@ +from __future__ import annotations + +import uuid +from typing import Any + +import httpx +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.fabric_graph import FabricGraphEdge, FabricGraphImport, FabricGraphNode +from api.schemas.fabric_graph import ( + FabricGraphActivationRequest, + FabricGraphActivationResult, + FabricGraphEdgeRead, + FabricGraphImportRead, + FabricGraphIngestResult, + FabricGraphNodeRead, + FabricGraphPullRequest, + FabricGraphSummary, +) +from api.services.fabric_graph import ( + FabricGraphValidationError, + activate_fabric_graph_import, + ingest_fabric_graph_export, + record_fabric_graph_error, + split_graph_ingest_body, +) +from api.services.repository_aliases import resolve_repository_slug + +router = APIRouter(prefix="/fabric", tags=["fabric"]) + + +@router.post("/graph-exports", response_model=FabricGraphIngestResult) +async def ingest_graph_export( + body: dict[str, Any] = Body(...), + source_repo_slug: str | None = Query(None), + source_url: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> FabricGraphIngestResult: + graph, metadata = split_graph_ingest_body(body) + effective_source_repo_slug = ( + source_repo_slug + or metadata.get("source_repo_slug") + or metadata.get("repo_slug") + or "railiance-fabric" + ) + effective_source_url = source_url or metadata.get("source_url") + requested_by = str(metadata.get("requested_by") or "api") + try: + import_run, created, idempotent = await ingest_fabric_graph_export( + session, + graph, + source_repo_slug=str(effective_source_repo_slug), + source_url=str(effective_source_url) if effective_source_url else None, + requested_by=requested_by, + ) + except FabricGraphValidationError as exc: + raise HTTPException(status_code=422, detail=exc.detail) from exc + return FabricGraphIngestResult( + import_run=FabricGraphImportRead.model_validate(import_run), + created=created, + idempotent=idempotent, + node_count=import_run.node_count, + edge_count=import_run.edge_count, + ) + + +@router.post("/graph-exports/pull", response_model=FabricGraphIngestResult) +async def pull_graph_export( + body: FabricGraphPullRequest | None = None, + session: AsyncSession = Depends(get_session), +) -> FabricGraphIngestResult: + request = body or FabricGraphPullRequest() + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.get(request.source_url) + response.raise_for_status() + payload = response.json() + except Exception as exc: + await record_fabric_graph_error( + session, + "Fabric graph export pull failed.", + source_repo_slug=request.source_repo_slug, + source_url=request.source_url, + error=str(exc), + requested_by=request.requested_by, + ) + raise HTTPException(status_code=502, detail=f"Fabric graph export pull failed: {exc}") from exc + + try: + import_run, created, idempotent = await ingest_fabric_graph_export( + session, + payload, + source_repo_slug=request.source_repo_slug, + source_url=request.source_url, + requested_by=request.requested_by, + ) + except FabricGraphValidationError as exc: + raise HTTPException(status_code=422, detail=exc.detail) from exc + return FabricGraphIngestResult( + import_run=FabricGraphImportRead.model_validate(import_run), + created=created, + idempotent=idempotent, + node_count=import_run.node_count, + edge_count=import_run.edge_count, + ) + + +@router.get("/graph-exports", response_model=list[FabricGraphImportRead]) +async def list_graph_imports( + source_repo_slug: str | None = None, + validation_status: str | None = None, + limit: int = Query(50, ge=1, le=500), + session: AsyncSession = Depends(get_session), +) -> list[FabricGraphImportRead]: + query = select(FabricGraphImport) + if source_repo_slug: + query = query.where(FabricGraphImport.source_repo_slug == source_repo_slug) + if validation_status: + query = query.where(FabricGraphImport.validation_status == validation_status) + query = query.order_by(FabricGraphImport.created_at.desc()).limit(limit) + result = await session.execute(query) + return [FabricGraphImportRead.model_validate(row) for row in result.scalars().all()] + + +@router.get("/graph-exports/latest", response_model=FabricGraphImportRead) +async def latest_graph_import( + source_repo_slug: str = "railiance-fabric", + session: AsyncSession = Depends(get_session), +) -> FabricGraphImportRead: + import_run = await _latest_valid_import(session, source_repo_slug) + if import_run is None: + raise HTTPException(status_code=404, detail=f"No valid Fabric graph import for '{source_repo_slug}'") + return FabricGraphImportRead.model_validate(import_run) + + +@router.post( + "/graph-exports/{import_id}/activate", + response_model=FabricGraphActivationResult, +) +async def activate_graph_import( + import_id: uuid.UUID, + body: FabricGraphActivationRequest | None = None, + session: AsyncSession = Depends(get_session), +) -> FabricGraphActivationResult: + request = body or FabricGraphActivationRequest() + try: + import_run, previous_import_id, activated = await activate_fabric_graph_import( + session, + import_id, + requested_by=request.requested_by, + ) + except ValueError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + if import_run is None: + raise HTTPException(status_code=404, detail=f"Fabric graph import '{import_id}' not found") + return FabricGraphActivationResult( + import_run=FabricGraphImportRead.model_validate(import_run), + previous_import_id=previous_import_id, + activated=activated, + idempotent=not activated, + ) + + +@router.get("/graph/nodes", response_model=list[FabricGraphNodeRead]) +async def list_graph_nodes( + source_repo_slug: str = "railiance-fabric", + domain: str | None = None, + repo: str | None = None, + canonical_category: str | None = None, + fabric_id: str | None = None, + subfabric_id: str | None = None, + owner_actor_id: str | None = None, + owner_role: str | None = None, + ownership_resolution: str | None = None, + cost_center_id: str | None = None, + profit_center_id: str | None = None, + evidence_state: str | None = None, + evidence_review_state: str | None = None, + mapping_fit: str | None = None, + kind: str | None = None, + unresolved_ownership: bool | None = None, + limit: int = Query(100, ge=1, le=1000), + session: AsyncSession = Depends(get_session), +) -> list[FabricGraphNodeRead]: + import_run = await _latest_valid_import_or_404(session, source_repo_slug) + query = select(FabricGraphNode).where(FabricGraphNode.import_id == import_run.id) + if domain: + query = query.where(FabricGraphNode.domain_slug == domain) + if repo: + resolution = await resolve_repository_slug(session, repo, required=False) + query = query.where( + FabricGraphNode.repo_slug.in_( + resolution.slug_values if resolution else (repo,) + ) + ) + if canonical_category: + query = query.where(FabricGraphNode.canon_category == canonical_category) + if fabric_id: + query = query.where(FabricGraphNode.fabric_id == fabric_id) + if subfabric_id: + query = query.where(FabricGraphNode.subfabric_id == subfabric_id) + if owner_actor_id: + query = query.where(FabricGraphNode.owner_actor_id == owner_actor_id) + if owner_role: + query = query.where(FabricGraphNode.owner_role == owner_role) + if ownership_resolution: + query = query.where(FabricGraphNode.ownership_resolution == ownership_resolution) + if cost_center_id: + query = query.where(FabricGraphNode.cost_center_id == cost_center_id) + if profit_center_id: + query = query.where(FabricGraphNode.profit_center_id == profit_center_id) + if evidence_state: + query = query.where(FabricGraphNode.evidence_state == evidence_state) + if evidence_review_state: + query = query.where(FabricGraphNode.evidence_review_state == evidence_review_state) + if mapping_fit: + query = query.where(FabricGraphNode.mapping_fit == mapping_fit) + if kind: + query = query.where(FabricGraphNode.kind == kind) + if unresolved_ownership is True: + query = query.where( + or_( + FabricGraphNode.owner_actor_id.is_(None), + FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")), + ) + ) + elif unresolved_ownership is False: + query = query.where( + FabricGraphNode.owner_actor_id.is_not(None), + ~FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")), + ) + query = query.order_by(FabricGraphNode.graph_id).limit(limit) + result = await session.execute(query) + return [FabricGraphNodeRead.model_validate(row) for row in result.scalars().all()] + + +@router.get("/graph/edges", response_model=list[FabricGraphEdgeRead]) +async def list_graph_edges( + source_repo_slug: str = "railiance-fabric", + canonical_relationship: str | None = None, + edge_type: str | None = None, + relationship_category: str | None = None, + provider_owner_actor_id: str | None = None, + consumer_owner_actor_id: str | None = None, + provider_fabric_id: str | None = None, + consumer_fabric_id: str | None = None, + provider_subfabric_id: str | None = None, + consumer_subfabric_id: str | None = None, + crosses_fabric_boundary: bool | None = None, + crosses_subfabric_boundary: bool | None = None, + utility_type: str | None = None, + utility_business_model: str | None = None, + utility_payment_schema_id: str | None = None, + cost_center_id: str | None = None, + profit_center_id: str | None = None, + provider_profit_center_id: str | None = None, + consumer_cost_center_id: str | None = None, + evidence_state: str | None = None, + evidence_review_state: str | None = None, + mapping_fit: str | None = None, + display_only: bool | None = None, + missing_payment_schema: bool | None = None, + from_graph_id: str | None = None, + to_graph_id: str | None = None, + limit: int = Query(100, ge=1, le=1000), + session: AsyncSession = Depends(get_session), +) -> list[FabricGraphEdgeRead]: + import_run = await _latest_valid_import_or_404(session, source_repo_slug) + query = select(FabricGraphEdge).where(FabricGraphEdge.import_id == import_run.id) + if canonical_relationship: + query = query.where(FabricGraphEdge.canonical_type == canonical_relationship) + if edge_type: + query = query.where(FabricGraphEdge.edge_type == edge_type) + if relationship_category: + query = query.where(FabricGraphEdge.relationship_category == relationship_category) + if provider_owner_actor_id: + query = query.where(FabricGraphEdge.provider_owner_actor_id == provider_owner_actor_id) + if consumer_owner_actor_id: + query = query.where(FabricGraphEdge.consumer_owner_actor_id == consumer_owner_actor_id) + if provider_fabric_id: + query = query.where(FabricGraphEdge.provider_fabric_id == provider_fabric_id) + if consumer_fabric_id: + query = query.where(FabricGraphEdge.consumer_fabric_id == consumer_fabric_id) + if provider_subfabric_id: + query = query.where(FabricGraphEdge.provider_subfabric_id == provider_subfabric_id) + if consumer_subfabric_id: + query = query.where(FabricGraphEdge.consumer_subfabric_id == consumer_subfabric_id) + if crosses_fabric_boundary is not None: + query = query.where(FabricGraphEdge.crosses_fabric_boundary == crosses_fabric_boundary) + if crosses_subfabric_boundary is not None: + query = query.where(FabricGraphEdge.crosses_subfabric_boundary == crosses_subfabric_boundary) + if utility_type: + query = query.where(FabricGraphEdge.utility_type == utility_type) + if utility_business_model: + query = query.where(FabricGraphEdge.utility_business_model == utility_business_model) + if utility_payment_schema_id: + query = query.where(FabricGraphEdge.utility_payment_schema_id == utility_payment_schema_id) + if cost_center_id: + query = query.where(FabricGraphEdge.cost_center_id == cost_center_id) + if profit_center_id: + query = query.where(FabricGraphEdge.profit_center_id == profit_center_id) + if provider_profit_center_id: + query = query.where(FabricGraphEdge.provider_profit_center_id == provider_profit_center_id) + if consumer_cost_center_id: + query = query.where(FabricGraphEdge.consumer_cost_center_id == consumer_cost_center_id) + if evidence_state: + query = query.where(FabricGraphEdge.evidence_state == evidence_state) + if evidence_review_state: + query = query.where(FabricGraphEdge.evidence_review_state == evidence_review_state) + if mapping_fit: + query = query.where(FabricGraphEdge.mapping_fit == mapping_fit) + if display_only is not None: + query = query.where(FabricGraphEdge.display_only == display_only) + if missing_payment_schema is True: + query = query.where( + FabricGraphEdge.relationship_category == "utility", + FabricGraphEdge.utility_payment_schema_id.is_(None), + ) + elif missing_payment_schema is False: + query = query.where( + or_( + FabricGraphEdge.relationship_category != "utility", + FabricGraphEdge.utility_payment_schema_id.is_not(None), + ) + ) + if from_graph_id: + query = query.where(FabricGraphEdge.from_graph_id == from_graph_id) + if to_graph_id: + query = query.where(FabricGraphEdge.to_graph_id == to_graph_id) + query = query.order_by(FabricGraphEdge.from_graph_id, FabricGraphEdge.to_graph_id, FabricGraphEdge.edge_type).limit(limit) + result = await session.execute(query) + return [FabricGraphEdgeRead.model_validate(row) for row in result.scalars().all()] + + +@router.get("/graph/summary", response_model=FabricGraphSummary) +async def graph_summary( + source_repo_slug: str = "railiance-fabric", + session: AsyncSession = Depends(get_session), +) -> FabricGraphSummary: + import_run = await _latest_valid_import(session, source_repo_slug) + if import_run is None: + return FabricGraphSummary( + source_repo_slug=source_repo_slug, + latest_import=None, + node_count=0, + edge_count=0, + schema_version=None, + netkingdom_id=None, + actor_count=0, + fabric_count=0, + unresolved_count=0, + nodes_by_domain={}, + nodes_by_repo={}, + nodes_by_canon_category={}, + nodes_by_fabric={}, + nodes_by_subfabric={}, + nodes_by_owner_actor={}, + nodes_by_owner_role={}, + nodes_by_ownership_resolution={}, + edges_by_canonical_type={}, + edges_by_relationship_category={}, + utility_edges_by_provider_owner={}, + utility_edges_by_consumer_owner={}, + utility_edges_by_business_model={}, + nodes_by_evidence_state={}, + edges_by_evidence_state={}, + nodes_by_mapping_fit={}, + edges_by_mapping_fit={}, + tenant_utilities_without_payment_schema=0, + nodes_without_accounting_attribution=0, + unresolved_ownership_count=0, + unresolved_accounting_count=0, + example_nodes=[], + example_edges=[], + ) + + return FabricGraphSummary( + source_repo_slug=source_repo_slug, + latest_import=FabricGraphImportRead.model_validate(import_run), + node_count=import_run.node_count, + edge_count=import_run.edge_count, + schema_version=import_run.schema_version, + netkingdom_id=import_run.netkingdom_id, + actor_count=import_run.actor_count, + fabric_count=import_run.fabric_count, + unresolved_count=import_run.unresolved_count, + nodes_by_domain=await _counts(session, FabricGraphNode.domain_slug, import_run.id), + nodes_by_repo=await _counts(session, FabricGraphNode.repo_slug, import_run.id), + nodes_by_canon_category=await _counts(session, FabricGraphNode.canon_category, import_run.id), + nodes_by_fabric=await _counts(session, FabricGraphNode.fabric_id, import_run.id), + nodes_by_subfabric=await _counts(session, FabricGraphNode.subfabric_id, import_run.id), + nodes_by_owner_actor=await _counts(session, FabricGraphNode.owner_actor_id, import_run.id), + nodes_by_owner_role=await _counts(session, FabricGraphNode.owner_role, import_run.id), + nodes_by_ownership_resolution=await _counts(session, FabricGraphNode.ownership_resolution, import_run.id), + edges_by_canonical_type=await _counts(session, FabricGraphEdge.canonical_type, import_run.id), + edges_by_relationship_category=await _counts(session, FabricGraphEdge.relationship_category, import_run.id), + utility_edges_by_provider_owner=await _counts( + session, + FabricGraphEdge.provider_owner_actor_id, + import_run.id, + FabricGraphEdge.relationship_category == "utility", + ), + utility_edges_by_consumer_owner=await _counts( + session, + FabricGraphEdge.consumer_owner_actor_id, + import_run.id, + FabricGraphEdge.relationship_category == "utility", + ), + utility_edges_by_business_model=await _counts( + session, + FabricGraphEdge.utility_business_model, + import_run.id, + FabricGraphEdge.relationship_category == "utility", + ), + nodes_by_evidence_state=await _counts(session, FabricGraphNode.evidence_state, import_run.id), + edges_by_evidence_state=await _counts(session, FabricGraphEdge.evidence_state, import_run.id), + nodes_by_mapping_fit=await _counts(session, FabricGraphNode.mapping_fit, import_run.id), + edges_by_mapping_fit=await _counts(session, FabricGraphEdge.mapping_fit, import_run.id), + tenant_utilities_without_payment_schema=await _count_rows( + session, + FabricGraphEdge, + import_run.id, + FabricGraphEdge.relationship_category == "utility", + FabricGraphEdge.utility_business_model == "tenant_utility", + FabricGraphEdge.utility_payment_schema_id.is_(None), + ), + nodes_without_accounting_attribution=await _count_rows( + session, + FabricGraphNode, + import_run.id, + FabricGraphNode.cost_center_id.is_(None), + FabricGraphNode.profit_center_id.is_(None), + ), + unresolved_ownership_count=await _count_rows( + session, + FabricGraphNode, + import_run.id, + or_( + FabricGraphNode.owner_actor_id.is_(None), + FabricGraphNode.ownership_resolution.in_(("unresolved", "ambiguous")), + ), + ), + unresolved_accounting_count=await _count_rows( + session, + FabricGraphEdge, + import_run.id, + FabricGraphEdge.relationship_category == "utility", + FabricGraphEdge.utility_payment_schema_id.is_(None), + ), + example_nodes=await _example_nodes(session, import_run.id), + example_edges=await _example_edges(session, import_run.id), + ) + + +async def _latest_valid_import(session: AsyncSession, source_repo_slug: str) -> FabricGraphImport | None: + result = await session.execute( + select(FabricGraphImport) + .where( + FabricGraphImport.source_repo_slug == source_repo_slug, + FabricGraphImport.validation_status == "valid", + FabricGraphImport.is_latest.is_(True), + ) + .order_by(FabricGraphImport.created_at.desc()) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def _latest_valid_import_or_404(session: AsyncSession, source_repo_slug: str) -> FabricGraphImport: + import_run = await _latest_valid_import(session, source_repo_slug) + if import_run is None: + raise HTTPException(status_code=404, detail=f"No valid Fabric graph import for '{source_repo_slug}'") + return import_run + + +async def _counts(session: AsyncSession, column: Any, import_id: Any, *conditions: Any) -> dict[str, int]: + table = column.class_ + result = await session.execute( + select(column, func.count()) + .select_from(table) + .where(table.import_id == import_id, column.is_not(None), *conditions) + .group_by(column) + ) + return {str(key): int(count) for key, count in result.all() if key is not None} + + +async def _count_rows(session: AsyncSession, table: Any, import_id: Any, *conditions: Any) -> int: + result = await session.execute( + select(func.count()).select_from(table).where(table.import_id == import_id, *conditions) + ) + return int(result.scalar_one()) + + +async def _example_nodes(session: AsyncSession, import_id: Any) -> list[FabricGraphNodeRead]: + result = await session.execute( + select(FabricGraphNode) + .where(FabricGraphNode.import_id == import_id) + .order_by(FabricGraphNode.graph_id) + .limit(5) + ) + return [FabricGraphNodeRead.model_validate(row) for row in result.scalars().all()] + + +async def _example_edges(session: AsyncSession, import_id: Any) -> list[FabricGraphEdgeRead]: + result = await session.execute( + select(FabricGraphEdge) + .where(FabricGraphEdge.import_id == import_id) + .order_by(FabricGraphEdge.from_graph_id, FabricGraphEdge.to_graph_id, FabricGraphEdge.edge_type) + .limit(5) + ) + return [FabricGraphEdgeRead.model_validate(row) for row in result.scalars().all()] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/flows.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/flows.py new file mode 100644 index 00000000..176fc1dc --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/flows.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.flow_defs import ( + assertion_result_to_dict, + create_flow_engine, + flow_result_to_dict, + load_flow, +) +from api.models.capability_request import CapabilityRequest +from api.models.contribution import Contribution +from api.models.task import Task, TaskStatus +from api.models.workplan import Workplan +from api.models.workplan_dependency import WorkplanDependency +from api.services.lifecycle import transition_task_status, transition_workplan_status +from api.workplan_status import normalize_workplan_status + +router = APIRouter(prefix="/flows", tags=["flows"]) + +_WORKPLAN_FLOW_ENTITY_TYPES = frozenset({"workplan", "workstream"}) + + +def _is_workplan_flow(entity_type: str) -> bool: + return entity_type in _WORKPLAN_FLOW_ENTITY_TYPES + + +@router.get("/definitions") +async def list_flow_definitions() -> list[dict[str, Any]]: + flows = [ + load_flow(entity_type) + for entity_type in ( + "workplan", + "workstream", + "task", + "contribution", + "capability_request", + ) + ] + return [ + { + "id": flow.id, + "entity_type": flow.entity_type, + "workstations": [ + { + "name": workstation.name, + "description": workstation.description, + "entry_assertion_count": len(workstation.entry_assertions), + "exit_assertion_count": len(workstation.exit_assertions), + } + for workstation in flow.workstations + ], + } + for flow in flows + ] + + +@router.get("/{entity_type}/{entity_id}") +async def get_flow_state( + entity_type: str, + entity_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + obj = await _flow_object(entity_type, entity_id, session) + flow = load_flow(entity_type) + result = create_flow_engine().evaluate(obj, flow) + return flow_result_to_dict(result) + + +@router.post("/{entity_type}/{entity_id}/advance/{target_workstation}") +async def advance_workstation( + entity_type: str, + entity_id: uuid.UUID, + target_workstation: str, + session: AsyncSession = Depends(get_session), +) -> dict[str, Any]: + obj = await _flow_object(entity_type, entity_id, session) + flow = load_flow(entity_type) + engine = create_flow_engine() + can_reach, failures = engine.can_reach(obj, flow, target_workstation) + if not can_reach: + raise HTTPException( + status_code=409, + detail={ + "message": ( + f"Cannot advance {entity_type} '{entity_id}' " + f"to '{target_workstation}'." + ), + "blocking_assertions": [ + assertion_result_to_dict(item) for item in failures + ], + "flow_result": flow_result_to_dict(engine.evaluate(obj, flow)), + }, + ) + + entity = await _entity(entity_type, entity_id, session) + if _is_workplan_flow(entity_type): + transition_workplan_status(entity, target_workstation) + elif entity_type == "task": + parent = await session.get(Workplan, entity.workplan_id) + transition_task_status( + entity, + target_workstation, + parent_workstream=parent, + status_coercer=TaskStatus, + ) + else: + entity.status = target_workstation + await session.commit() + await session.refresh(entity) + return await get_flow_state(entity_type, entity_id, session) + + +async def _flow_object( + entity_type: str, + entity_id: uuid.UUID, + session: AsyncSession, +) -> dict[str, Any]: + entity = await _entity(entity_type, entity_id, session) + status = _value(entity.status) + current_status = ( + normalize_workplan_status(status) if _is_workplan_flow(entity_type) else status + ) + obj: dict[str, Any] = { + "id": str(entity.id), + "status": current_status, + "workstation": current_status, + "previous_workstation": current_status, + } + + if _is_workplan_flow(entity_type): + tasks = list((await session.execute( + select(Task).where(Task.workplan_id == entity_id) + )).scalars().all()) + deps = list((await session.execute( + select(WorkplanDependency).where( + WorkplanDependency.from_workplan_id == entity_id + ) + )).scalars().all()) + dependency_ids = [dep.to_workplan_id for dep in deps] + dependency_workstations: list[dict[str, Any]] = [] + if dependency_ids: + dep_ws = list((await session.execute( + select(Workplan).where(Workplan.id.in_(dependency_ids)) + )).scalars().all()) + dependency_workstations = [ + {"id": str(ws.id), "workstation": normalize_workplan_status(ws.status)} + for ws in dep_ws + ] + obj.update({ + "tasks": [{"id": str(task.id), "status": _value(task.status)} for task in tasks], + "dependencies": dependency_workstations, + }) + elif entity_type == "task": + obj.update({ + "needs_human": entity.needs_human, + "blocking_reason": entity.blocking_reason, + }) + + return obj + + +async def _entity( + entity_type: str, + entity_id: uuid.UUID, + session: AsyncSession, +): + model_by_type = { + "workplan": Workplan, + "workstream": Workplan, + "task": Task, + "contribution": Contribution, + "capability_request": CapabilityRequest, + } + model = model_by_type.get(entity_type) + if model is None: + raise HTTPException(status_code=404, detail=f"Unknown flow entity type '{entity_type}'") + entity = await session.get(model, entity_id) + if entity is None: + raise HTTPException(status_code=404, detail=f"{entity_type} '{entity_id}' not found") + return entity + + +def _value(item): + return item.value if hasattr(item, "value") else item diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/identifier_migrations.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/identifier_migrations.py new file mode 100644 index 00000000..95cd6470 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/identifier_migrations.py @@ -0,0 +1,118 @@ +from dataclasses import asdict + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.config import settings +from api.schemas.identifier_migration import ( + IdentifierMigrationApplySubmit, + SealedProjectionRepairReceiptRead, + SealedProjectionRepairSubmit, +) +from api.services.work_record_identifier_migration import ( + IdentifierMigrationError, + apply_repository_identifier_migration, + repair_absent_prederivation_projection, + reverse_repository_identifier_migration, +) + +router = APIRouter(prefix="/identifier-migrations", tags=["identifier-migrations"]) + + +@router.post("/repositories/{repo_slug}/apply") +async def apply_identifier_migration( + repo_slug: str, + body: IdentifierMigrationApplySubmit, + session: AsyncSession = Depends(get_session), +) -> dict: + """Apply one sealed repository-atomic identifier convergence transaction.""" + if settings.state_hub_instance_role != "primary": + raise HTTPException( + status_code=409, + detail={ + "message": "identifier migration writes require the primary State Hub", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + }, + ) + if not body.primary_confirmed: + raise HTTPException(status_code=409, detail="explicit primary confirmation is required") + if body.plan.get("plan_sha256") != body.expected_plan_sha256: + raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan") + try: + result = await apply_repository_identifier_migration(session, body.plan, repo_slug) + except IdentifierMigrationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + from api.routers.workstreams import _invalidate_workplan_index_cache + + _invalidate_workplan_index_cache() + return { + "schema": "state-hub.identifier-migration-apply.v1", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + "result": asdict(result), + } + + +@router.post("/repositories/{repo_slug}/reverse") +async def reverse_identifier_migration( + repo_slug: str, + body: IdentifierMigrationApplySubmit, + session: AsyncSession = Depends(get_session), +) -> dict: + """Reverse one sealed repository migration after a failed file phase.""" + if settings.state_hub_instance_role != "primary": + raise HTTPException( + status_code=409, + detail={ + "message": "identifier migration writes require the primary State Hub", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + }, + ) + if not body.primary_confirmed: + raise HTTPException(status_code=409, detail="explicit primary confirmation is required") + if body.plan.get("plan_sha256") != body.expected_plan_sha256: + raise HTTPException(status_code=409, detail="explicit plan SHA-256 does not match sealed plan") + try: + result = await reverse_repository_identifier_migration(session, body.plan, repo_slug) + except IdentifierMigrationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + from api.routers.workstreams import _invalidate_workplan_index_cache + + _invalidate_workplan_index_cache() + return { + "schema": "state-hub.identifier-migration-reverse.v1", + "instance_role": settings.state_hub_instance_role, + "instance_label": settings.state_hub_instance_label, + "result": asdict(result), + } + + +@router.post( + "/sealed-projection-repairs", + response_model=SealedProjectionRepairReceiptRead, +) +async def repair_sealed_projection( + body: SealedProjectionRepairSubmit, + session: AsyncSession = Depends(get_session), +) -> dict: + """Restore one exact pre-derivation workplan unit into an absent projection.""" + try: + receipt = await repair_absent_prederivation_projection( + session, + body.plan, + body.repo_slug, + body.unit, + expected_plan_sha256=body.expected_plan_sha256, + source_revision=body.source_revision, + source_fingerprint=body.source_fingerprint, + source_clean=body.source_clean, + source_synchronized=body.source_synchronized, + primary_confirmed=body.primary_confirmed, + projection_identity=body.projection_identity, + ) + except IdentifierMigrationError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + return asdict(receipt) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/intake.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/intake.py new file mode 100644 index 00000000..23012052 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/intake.py @@ -0,0 +1,173 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.intake import Intake, IntakeNote, IntakeOutcome, IntakeStatus +from api.models.progress_event import ProgressEvent +from api.schemas.intake import ( + IntakeClose, + IntakeCreate, + IntakeNoteCreate, + IntakeRead, + IntakeRoute, + IntakeUpdate, +) + +router = APIRouter(prefix="/intakes", tags=["intakes"]) + +_ALLOWED_ROUTE_FROM = {IntakeStatus.open, IntakeStatus.vetted} +_ALLOWED_CLOSE_FROM = {IntakeStatus.open, IntakeStatus.vetted, IntakeStatus.routed} + + +def _reject_status(intake: Intake, allowed: set[IntakeStatus], action: str) -> None: + if intake.status not in allowed: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + f"Cannot {action} intake in status '{intake.status.value}'; " + f"allowed from: {sorted(s.value for s in allowed)}" + ), + ) + + +@router.get("/", response_model=list[IntakeRead]) +async def list_intakes( + topic_id: uuid.UUID | None = None, + workplan_id: uuid.UUID | None = None, + repo_id: uuid.UUID | None = None, + status_: IntakeStatus | None = None, + session: AsyncSession = Depends(get_session), +) -> list[Intake]: + q = select(Intake) + if topic_id: + q = q.where(Intake.topic_id == topic_id) + if workplan_id: + q = q.where(Intake.workplan_id == workplan_id) + if repo_id: + q = q.where(Intake.repo_id == repo_id) + if status_: + q = q.where(Intake.status == status_) + q = q.order_by(Intake.created_at) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=IntakeRead, status_code=status.HTTP_201_CREATED) +async def create_intake( + body: IntakeCreate, + session: AsyncSession = Depends(get_session), +) -> Intake: + intake = Intake(**body.model_dump()) + session.add(intake) + await session.commit() + await session.refresh(intake) + return intake + + +@router.get("/{intake_id}", response_model=IntakeRead) +async def get_intake( + intake_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> Intake: + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + return intake + + +@router.patch("/{intake_id}", response_model=IntakeRead) +async def update_intake( + intake_id: uuid.UUID, + body: IntakeUpdate, + session: AsyncSession = Depends(get_session), +) -> Intake: + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(intake, field, value) + await session.commit() + await session.refresh(intake) + return intake + + +@router.post("/{intake_id}/route", response_model=IntakeRead) +async def route_intake( + intake_id: uuid.UUID, + body: IntakeRoute, + session: AsyncSession = Depends(get_session), +) -> Intake: + """Move an intake into `routed` — eligible for the promotion transition.""" + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + _reject_status(intake, _ALLOWED_ROUTE_FROM, "route") + + intake.status = IntakeStatus.routed + if body.routed_note: + intake.routed_note = body.routed_note + await session.commit() + await session.refresh(intake) + return intake + + +@router.post("/{intake_id}/close", response_model=IntakeRead) +async def close_intake( + intake_id: uuid.UUID, + body: IntakeClose, + session: AsyncSession = Depends(get_session), +) -> Intake: + """Close an intake with an outcome. `outcome=promoted` requires + `promoted_to` (the canonical id of the record it became) — this is + normally called by the promotion transition (CUST-WP-0061-T03), not by + hand, but a manual close (declined/absorbed, or a promotion recorded + after the fact) is supported directly.""" + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + _reject_status(intake, _ALLOWED_CLOSE_FROM, "close") + + intake.status = IntakeStatus.closed + intake.outcome = body.outcome + intake.closed_at = datetime.now(tz=timezone.utc) + if body.promoted_to: + intake.promoted_to = body.promoted_to + await session.commit() + await session.refresh(intake) + + event = ProgressEvent( + topic_id=intake.topic_id, + workplan_id=intake.workplan_id, + event_type="intake_closed", + summary=f"Intake closed ({body.outcome.value}): {intake.title}", + detail={ + "intake_id": str(intake.id), + "outcome": body.outcome.value, + "promoted_to": body.promoted_to, + "note": body.note, + }, + ) + session.add(event) + await session.commit() + + return intake + + +@router.post("/{intake_id}/notes", response_model=IntakeRead, status_code=status.HTTP_201_CREATED) +async def add_intake_note( + intake_id: uuid.UUID, + body: IntakeNoteCreate, + session: AsyncSession = Depends(get_session), +) -> Intake: + intake = await session.get(Intake, intake_id) + if intake is None: + raise HTTPException(status_code=404, detail="Intake not found") + note = IntakeNote(intake_id=intake.id, author=body.author, content=body.content) + session.add(note) + await session.commit() + await session.refresh(intake) + return intake diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/interface_changes.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/interface_changes.py new file mode 100644 index 00000000..078557e4 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/interface_changes.py @@ -0,0 +1,209 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.agent_message import AgentMessage +from api.models.interface_change import InterfaceChange +from api.models.progress_event import ProgressEvent +from api.schemas.interface_change import ( + InterfaceChangeCreate, + InterfaceChangePatch, + InterfaceChangeRead, +) +from api.services.repository_aliases import ( + affected_slug_predicate, + canonicalize_repository_slug, + resolve_repository_slug, +) + +router = APIRouter(prefix="/interface-changes", tags=["interface-changes"]) + +_VALID_INTERFACE_TYPES = {"rest_api", "mcp_tool", "cli", "schema", "capability"} +_VALID_CHANGE_TYPES = {"breaking", "additive", "deprecation", "removal"} + + +@router.post("/", response_model=InterfaceChangeRead, status_code=status.HTTP_201_CREATED) +async def create_interface_change( + body: InterfaceChangeCreate, + session: AsyncSession = Depends(get_session), +) -> InterfaceChangeRead: + if body.interface_type not in _VALID_INTERFACE_TYPES: + raise HTTPException(status_code=422, detail=f"interface_type must be one of {sorted(_VALID_INTERFACE_TYPES)}") + if body.change_type not in _VALID_CHANGE_TYPES: + raise HTTPException(status_code=422, detail=f"change_type must be one of {sorted(_VALID_CHANGE_TYPES)}") + + resolution = await resolve_repository_slug(session, body.repo_slug) + repo = resolution.repo + affected_repo_slugs = [ + await canonicalize_repository_slug(session, slug) + for slug in body.affected_repo_slugs + ] + change = InterfaceChange( + repo_id=repo.id, + interface_type=body.interface_type, + change_type=body.change_type, + title=body.title, + description=body.description, + affected_paths=body.affected_paths, + affected_repo_slugs=affected_repo_slugs, + planned_for=body.planned_for, + author=body.author, + status="draft", + ) + session.add(change) + await session.commit() + await session.refresh(change) + return InterfaceChangeRead.from_orm_with_slug(change) + + +@router.get("/", response_model=list[InterfaceChangeRead]) +async def list_interface_changes( + repo_slug: str | None = Query(None), + status: str | None = Query(None), + change_type: str | None = Query(None), + affected_repo: str | None = Query(None, description="Return changes that affect this repo slug"), + session: AsyncSession = Depends(get_session), +) -> list[InterfaceChangeRead]: + q = select(InterfaceChange).order_by(InterfaceChange.created_at.desc()) + if repo_slug: + repo = await _repo_by_slug(repo_slug, session) + q = q.where(InterfaceChange.repo_id == repo.id) + if status: + q = q.where(InterfaceChange.status == status) + if change_type: + q = q.where(InterfaceChange.change_type == change_type) + if affected_repo: + resolution = await resolve_repository_slug( + session, affected_repo, required=False + ) + values = resolution.slug_values if resolution else (affected_repo,) + q = q.where(affected_slug_predicate(InterfaceChange.affected_repo_slugs, values)) + result = await session.execute(q) + return [InterfaceChangeRead.from_orm_with_slug(c) for c in result.scalars().all()] + + +@router.get("/{change_id}", response_model=InterfaceChangeRead) +async def get_interface_change( + change_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> InterfaceChangeRead: + change = await _get_or_404(change_id, session) + return InterfaceChangeRead.from_orm_with_slug(change) + + +@router.patch("/{change_id}", response_model=InterfaceChangeRead) +async def patch_interface_change( + change_id: uuid.UUID, + body: InterfaceChangePatch, + session: AsyncSession = Depends(get_session), +) -> InterfaceChangeRead: + change = await _get_or_404(change_id, session) + if change.status != "draft": + raise HTTPException( + status_code=409, + detail=f"Cannot edit a change with status '{change.status}'. Only draft records are mutable.", + ) + payload = body.model_dump(exclude_unset=True) + if payload.get("affected_repo_slugs") is not None: + payload["affected_repo_slugs"] = [ + await canonicalize_repository_slug(session, slug) + for slug in payload["affected_repo_slugs"] + ] + for field, value in payload.items(): + setattr(change, field, value) + await session.commit() + await session.refresh(change) + return InterfaceChangeRead.from_orm_with_slug(change) + + +@router.post("/{change_id}/publish", response_model=InterfaceChangeRead) +async def publish_interface_change( + change_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> InterfaceChangeRead: + change = await _get_or_404(change_id, session) + if change.status != "draft": + raise HTTPException( + status_code=409, + detail=f"Cannot publish a change with status '{change.status}'. Must be 'draft'.", + ) + now = datetime.now(tz=timezone.utc) + change.status = "published" + change.published_at = now + + # Send inbox notifications to agents of affected repos + affected = change.affected_repo_slugs or [] + for slug in affected: + target_slug = await canonicalize_repository_slug(session, slug) + paths_summary = ", ".join(change.affected_paths[:5]) if change.affected_paths else "see description" + if len(change.affected_paths) > 5: + paths_summary += f" (+{len(change.affected_paths) - 5} more)" + msg = AgentMessage( + from_agent=change.repo.slug, + to_agent=target_slug, + subject=f"[{change.change_type.upper()}] {change.title}", + body=( + f"**Interface change published by `{change.repo.slug}`**\n\n" + f"- Type: `{change.interface_type}` / `{change.change_type}`\n" + f"- Affected paths: {paths_summary}\n\n" + f"{change.description}\n\n" + f"Change ID: `{change.id}` — resolve with " + f"`POST /interface-changes/{change.id}/resolve` once adapted." + ), + ) + session.add(msg) + + # Progress event on the originating repo + session.add(ProgressEvent( + event_type="milestone", + summary=f"Interface change published: {change.title}", + detail={ + "change_id": str(change.id), + "change_type": change.change_type, + "interface_type": change.interface_type, + "affected_repos": affected, + "notifications_sent": len(affected), + }, + author=change.author, + )) + + await session.commit() + await session.refresh(change) + return InterfaceChangeRead.from_orm_with_slug(change) + + +@router.post("/{change_id}/resolve", response_model=InterfaceChangeRead) +async def resolve_interface_change( + change_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> InterfaceChangeRead: + change = await _get_or_404(change_id, session) + if change.status != "published": + raise HTTPException( + status_code=409, + detail=f"Cannot resolve a change with status '{change.status}'. Must be 'published'.", + ) + change.status = "resolved" + change.resolved_at = datetime.now(tz=timezone.utc) + await session.commit() + await session.refresh(change) + return InterfaceChangeRead.from_orm_with_slug(change) + + +async def _repo_by_slug(slug: str, session: AsyncSession): + resolution = await resolve_repository_slug(session, slug) + return resolution.repo + + +async def _get_or_404(change_id: uuid.UUID, session: AsyncSession) -> InterfaceChange: + result = await session.execute( + select(InterfaceChange).where(InterfaceChange.id == change_id) + ) + change = result.scalar_one_or_none() + if change is None: + raise HTTPException(status_code=404, detail=f"InterfaceChange '{change_id}' not found") + return change diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/legacy_meter.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/legacy_meter.py new file mode 100644 index 00000000..d3adf855 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/legacy_meter.py @@ -0,0 +1,164 @@ +import uuid +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.legacy_meter import LegacyInterface +from api.schemas.legacy_meter import ( + LegacyInterfacePatch, + LegacyInterfaceRead, + LegacyInterfaceRegister, + LegacyUsageRecord, + LegacyUsageSummary, + LegacyWeeklyReview, +) +from api.services.legacy_meter import ( + LegacyUsageIdentity, + get_legacy_interface_by_key, + legacy_usage_summary, + legacy_weekly_review, + list_legacy_interfaces, + patch_legacy_interface, + record_legacy_usage, + register_legacy_interface, +) + +router = APIRouter(prefix="/legacy-meter", tags=["legacy-meter"]) + + +def _review_window( + *, + days: int | None, + hours: int | None, + window_start: datetime | None, + window_end: datetime | None, +) -> tuple[datetime, datetime]: + end = window_end or datetime.now(tz=timezone.utc) + if window_start is not None: + return window_start, end + if hours is not None: + return end - timedelta(hours=hours), end + return end - timedelta(days=days or 7), end + + +@router.post( + "/interfaces", + response_model=LegacyInterfaceRead, + status_code=status.HTTP_201_CREATED, +) +async def register_interface( + body: LegacyInterfaceRegister, + session: AsyncSession = Depends(get_session), +) -> LegacyInterface: + try: + return await register_legacy_interface(session, **body.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.get("/interfaces", response_model=list[LegacyInterfaceRead]) +async def list_interfaces( + session: AsyncSession = Depends(get_session), +) -> list[LegacyInterface]: + return await list_legacy_interfaces(session) + + +@router.get("/interfaces/by-key", response_model=LegacyInterfaceRead) +async def get_interface_by_key( + interface_key: str = Query(...), + session: AsyncSession = Depends(get_session), +) -> LegacyInterface: + interface = await get_legacy_interface_by_key(session, interface_key) + if interface is None: + raise HTTPException(status_code=404, detail=f"Legacy interface '{interface_key}' not found") + return interface + + +@router.patch("/interfaces/{interface_id}", response_model=LegacyInterfaceRead) +async def patch_interface( + interface_id: uuid.UUID, + body: LegacyInterfacePatch, + session: AsyncSession = Depends(get_session), +) -> LegacyInterface: + interface = await session.get(LegacyInterface, interface_id) + if interface is None: + raise HTTPException(status_code=404, detail=f"Legacy interface '{interface_id}' not found") + try: + return await patch_legacy_interface( + session, + interface, + body.model_dump(exclude_unset=True), + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.post("/usage", response_model=LegacyInterfaceRead, status_code=status.HTTP_201_CREATED) +async def record_usage( + body: LegacyUsageRecord, + session: AsyncSession = Depends(get_session), +) -> LegacyInterface: + try: + return await record_legacy_usage( + session, + interface_key=body.interface_key, + interface_kind=body.interface_kind, + replacement_ref=body.replacement_ref, + owner_component=body.owner_component, + replacement_verified=body.replacement_verified, + identity=LegacyUsageIdentity( + tenant_key=body.tenant_key or "unknown", + user_key=body.user_key or "unknown", + component_key=body.component_key or "unknown", + ), + observed_at=body.observed_at, + call_count=body.call_count, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.get("/summary", response_model=LegacyUsageSummary) +async def usage_summary( + days: int | None = Query(None, ge=1, le=366), + hours: int | None = Query(None, ge=1, le=8784), + window_start: datetime | None = None, + window_end: datetime | None = None, + session: AsyncSession = Depends(get_session), +) -> LegacyUsageSummary: + if days is not None and hours is not None: + raise HTTPException( + status_code=422, + detail="Pass either days or hours, not both", + ) + start, end = _review_window( + days=days, + hours=hours, + window_start=window_start, + window_end=window_end, + ) + return await legacy_usage_summary(session, window_start=start, window_end=end) + + +@router.get("/weekly-review", response_model=LegacyWeeklyReview) +async def weekly_review( + days: int | None = Query(None, ge=1, le=366), + hours: int | None = Query(None, ge=1, le=8784), + window_start: datetime | None = None, + window_end: datetime | None = None, + session: AsyncSession = Depends(get_session), +) -> LegacyWeeklyReview: + if days is not None and hours is not None: + raise HTTPException( + status_code=422, + detail="Pass either days or hours, not both", + ) + start, end = _review_window( + days=days, + hours=hours, + window_start=window_start, + window_end=window_end, + ) + return await legacy_weekly_review(session, window_start=start, window_end=end) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/memo_notifications.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/memo_notifications.py new file mode 100644 index 00000000..3c6d8a0d --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/memo_notifications.py @@ -0,0 +1,97 @@ +"""Notification intake for informed-decision delivery ids. + +Intentionally absent from the 14-day write-idempotency allowlist: retention +is this receipt row, and a conflicting reuse must stay readable by GET. +""" +from __future__ import annotations + +import json + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.memo_notification import MemoNotificationReceipt +from api.services.informed_decision_contract import ( + MAX_NOTIFICATION_BYTES, + ContractError, + parse_delivery_id, + parse_notification, +) +from api.services.memo_notification import NotificationConflict, accept_notification + +router = APIRouter(prefix="/informed-decision", tags=["informed-decision-notifications"]) + + +def _error(status: int, code: str) -> JSONResponse: + return JSONResponse( + status_code=status, + content={"error": code}, + headers={"Cache-Control": "no-store"}, + ) + + +def _payload(receipt: MemoNotificationReceipt, *, status: int, replay: bool = False) -> JSONResponse: + return JSONResponse( + status_code=status, + content={ + "delivery_id": receipt.delivery_id, + "memo_id": receipt.memo_id, + "question": receipt.question, + "memo_class": receipt.memo_class, + "submitter": receipt.submitter, + "message_id": str(receipt.message_id), + "status": "accepted", + "authoritative_review": receipt.authoritative_review, + }, + headers={ + "Cache-Control": "no-store", + **({"X-StateHub-Idempotency-Replay": "true"} if replay else {}), + }, + ) + + +@router.post("/notifications") +async def post_notification( + request: Request, session: AsyncSession = Depends(get_session) +) -> JSONResponse: + raw = await request.body() + if len(raw) > MAX_NOTIFICATION_BYTES: + return _error(413, "notification_too_large") + content_type = request.headers.get("content-type", "") + if content_type.split(";")[0].strip().lower() != "application/json": + return _error(415, "json_required") + try: + delivery_id = parse_delivery_id(request.headers.get("idempotency-key")) + notice = parse_notification(json.loads(raw)) + except ContractError as exc: + return _error(exc.status, exc.code) + except (json.JSONDecodeError, UnicodeError): + return _error(400, "invalid_json") + try: + receipt, replay = await accept_notification(session, delivery_id, notice) + except NotificationConflict as exc: + return JSONResponse( + status_code=409, + content={ + "error": "notification_conflict", + "delivery_id": exc.receipt.delivery_id, + }, + headers={"Cache-Control": "no-store"}, + ) + return _payload(receipt, status=200 if replay else 201, replay=replay) + + +@router.get("/notifications/{delivery_id}") +async def get_notification( + delivery_id: str, session: AsyncSession = Depends(get_session) +) -> JSONResponse: + try: + checked = parse_delivery_id(delivery_id) + except ContractError as exc: + return _error(exc.status, exc.code) + receipt = await session.get(MemoNotificationReceipt, checked) + if receipt is None: + return _error(404, "notification_not_found") + return _payload(receipt, status=200) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/message_feed.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/message_feed.py new file mode 100644 index 00000000..f9bd4902 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/message_feed.py @@ -0,0 +1,277 @@ +"""Inbox change feed (STATE-WP-0098-T05). + +Read-only pull feed for derived inbox copies (hub-core). Spec: +docs/inbox-change-feed-contract.md §2, §3, §5. Revisions are assigned by the +database trigger from migration c4d5e6f7a8b9; one write transaction = one +revision, and an advisory lock makes commit order equal revision order, so the +maximum visible revision is a safe watermark. +""" +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, Depends, Query, Response +from fastapi.responses import JSONResponse +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from api.config import settings +from api.database import get_session +from api.schemas.inbox_feed import ( + CONTRACT, + CursorError, + FeedChange, + FeedPage, + FeedSnapshot, + ForeignCursorError, + decode_cursor, + encode_cursor, +) + +router = APIRouter(prefix="/messages/feed", tags=["messages", "inbox-feed"]) + +# Contract D1: tombstones are served for 90 days. +TOMBSTONE_HORIZON_DAYS = 90 +DEFAULT_LIMIT = 500 +MAX_LIMIT = 5000 +NO_STORE = {"Cache-Control": "no-store"} + +_MESSAGE_COLUMNS = ( + "id, from_agent, to_agent, subject, body, thread_id, read_at, archived_at, " + "kind, expires_at, supersedes_id, created_at, revision" +) +_RECEIPT_COLUMNS = "message_id, agent, delivered_at, read_at, acknowledged_at, revision" +_TOMBSTONE_COLUMNS = "message_id, revision, deleted_at, reason" + +# Revisions <= this value carry tombstones that fell out of the horizon; a +# cursor below it can no longer be served losslessly (410). +_COMPACTED_SQL = f""" +SELECT coalesce(max(revision), 0) FROM message_tombstones +WHERE deleted_at < now() - interval '{TOMBSTONE_HORIZON_DAYS} days' +""" +_HIGH_WATER_SQL = """ +SELECT greatest( + (SELECT coalesce(max(revision), 0) FROM agent_messages), + (SELECT coalesce(max(revision), 0) FROM message_receipts), + (SELECT coalesce(max(revision), 0) FROM message_tombstones) +) +""" +_REVISIONS_AFTER_SQL = """ +SELECT revision FROM agent_messages WHERE revision > :after +UNION ALL +SELECT revision FROM message_receipts WHERE revision > :after +UNION ALL +SELECT revision FROM message_tombstones WHERE revision > :after AND revision > :compacted +""" + + +def _jsonable(value: Any) -> Any: + if isinstance(value, uuid.UUID): + return str(value) + if isinstance(value, datetime): + return value.isoformat() + return value + + +def _row(mapping) -> dict[str, Any]: + return {key: _jsonable(value) for key, value in mapping.items()} + + +def receipt_key(state: dict[str, Any]) -> str: + return f"{state['message_id']}:{state['agent']}" + + +def content_hash(messages, receipts, tombstones) -> str: + payload = json.dumps( + {"messages": messages, "receipts": receipts, "tombstones": tombstones}, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(payload.encode()).hexdigest() + + +async def _begin_consistent_read(session: AsyncSession) -> None: + """Start one REPEATABLE READ READ ONLY transaction for the whole response.""" + await session.connection( + execution_options={"isolation_level": "REPEATABLE READ", "postgresql_readonly": True} + ) + + +async def _scalar(session: AsyncSession, sql: str, **params) -> Any: + return (await session.execute(text(sql), params)).scalar_one() + + +async def _rows(session: AsyncSession, sql: str, **params) -> list[dict[str, Any]]: + return [_row(m) for m in (await session.execute(text(sql), params)).mappings().all()] + + +@router.get("/snapshot", response_model=FeedSnapshot) +async def feed_snapshot( + response: Response, session: AsyncSession = Depends(get_session) +) -> FeedSnapshot: + response.headers.update(NO_STORE) + await _begin_consistent_read(session) + source_time = await _scalar(session, "SELECT now()") + compacted = await _scalar(session, _COMPACTED_SQL) + high_water = await _scalar(session, _HIGH_WATER_SQL) + messages = await _rows( + session, f"SELECT {_MESSAGE_COLUMNS} FROM agent_messages ORDER BY id" + ) + receipts = await _rows( + session, + f"SELECT {_RECEIPT_COLUMNS} FROM message_receipts ORDER BY message_id, agent", + ) + tombstones = await _rows( + session, + f"SELECT {_TOMBSTONE_COLUMNS} FROM message_tombstones " + "WHERE revision > :compacted ORDER BY message_id", + compacted=compacted, + ) + return FeedSnapshot( + source_instance=settings.state_hub_instance_label, + source_time=source_time, + cursor=encode_cursor(high_water), + compacted_before=encode_cursor(compacted), + messages=messages, + receipts=receipts, + tombstones=tombstones, + count=len(messages), + content_hash=content_hash(messages, receipts, tombstones), + ) + + +def _error(status_code: int, detail: str, **extra) -> JSONResponse: + return JSONResponse( + status_code=status_code, + content={"contract": CONTRACT, "detail": detail, **extra}, + headers=NO_STORE, + ) + + +@router.get( + "", + response_model=FeedPage, + responses={400: {}, 409: {}, 410: {}}, +) +async def feed_page( + response: Response, + after: str = Query(..., description="Cursor from a snapshot or previous page"), + limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), + source_instance: str | None = Query( + None, description="source_instance the cursor came from; 409 if foreign" + ), + session: AsyncSession = Depends(get_session), +): + response.headers.update(NO_STORE) + instance = settings.state_hub_instance_label + try: + after_rev = decode_cursor(after) + except ForeignCursorError as exc: + return _error(409, str(exc), resnapshot_required=True) + except CursorError as exc: + return _error(400, str(exc)) + if source_instance is not None and source_instance != instance: + return _error( + 409, + f"cursor from source_instance {source_instance!r}, this is {instance!r}", + resnapshot_required=True, + source_instance=instance, + ) + + await _begin_consistent_read(session) + source_time = await _scalar(session, "SELECT now()") + compacted = await _scalar(session, _COMPACTED_SQL) + high_water = await _scalar(session, _HIGH_WATER_SQL) + if after_rev > high_water: + # A cursor from the future: another instance, or a restored source. + return _error( + 409, + "cursor is ahead of this source's high_water; re-snapshot", + resnapshot_required=True, + source_instance=instance, + high_water=encode_cursor(high_water), + ) + if after_rev < compacted: + return _error( + 410, + "cursor is older than the tombstone horizon; re-snapshot", + resnapshot_required=True, + source_instance=instance, + compacted_before=encode_cursor(compacted), + ) + + params = {"after": after_rev, "compacted": compacted} + boundary = await _scalar( + session, + f"SELECT max(revision) FROM (SELECT revision FROM ({_REVISIONS_AFTER_SQL}) r " + "ORDER BY revision LIMIT :limit) page", + limit=limit, + **params, + ) + changes: list[FeedChange] = [] + has_more = False + if boundary is not None: + # A page never splits a revision: take every change up to the boundary. + window = {**params, "upto": boundary} + for state in await _rows( + session, + f"SELECT {_MESSAGE_COLUMNS} FROM agent_messages " + "WHERE revision > :after AND revision <= :upto " + "ORDER BY revision, created_at, id", + **window, + ): + changes.append( + FeedChange( + revision=state["revision"], entity="message", + key=state["id"], op="upsert", state=state, + ) + ) + for state in await _rows( + session, + f"SELECT {_RECEIPT_COLUMNS} FROM message_receipts " + "WHERE revision > :after AND revision <= :upto " + "ORDER BY revision, message_id, agent", + **window, + ): + changes.append( + FeedChange( + revision=state["revision"], entity="receipt", + key=receipt_key(state), op="upsert", state=state, + ) + ) + for state in await _rows( + session, + f"SELECT {_TOMBSTONE_COLUMNS} FROM message_tombstones " + "WHERE revision > :after AND revision > :compacted AND revision <= :upto " + "ORDER BY revision, message_id", + **window, + ): + changes.append( + FeedChange( + revision=state["revision"], entity="tombstone", + key=state["message_id"], op="delete", state=state, + ) + ) + # Stable sort: revision order; within a revision messages, receipts, tombstones. + changes.sort(key=lambda change: change.revision) + has_more = ( + await _scalar( + session, + f"SELECT exists(SELECT 1 FROM ({_REVISIONS_AFTER_SQL}) r " + "WHERE revision > :upto)", + **{**params, "upto": boundary}, + ) + ) + return FeedPage( + source_instance=instance, + source_time=source_time, + high_water=encode_cursor(high_water), + next_cursor=encode_cursor(boundary if boundary is not None else after_rev), + has_more=has_more, + compacted_before=encode_cursor(compacted), + changes=changes, + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/messages.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/messages.py new file mode 100644 index 00000000..99056dc3 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/messages.py @@ -0,0 +1,346 @@ +from datetime import datetime, timezone + +from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response, status +from sqlalchemy import and_, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.agent_message import BROADCAST, MESSAGE_KINDS, AgentMessage +from api.schemas.agent_message import ( + MessageAck, + MessageCreate, + MessageMarkRead, + MessageRead, + MessageReply, + NoticeStatus, +) +from api.services.legacy_meter import identity_from_request, record_legacy_usage +from api.services.message_receipts import ( + NEWS_DEFAULT_TTL, + broadcast_unread_clause, + is_broadcast, + notice_statuses, + receipts_for, + upsert_receipt, + utcnow, +) +from api.services.repository_aliases import ( + canonicalize_repository_slug, + resolve_repository_slug, +) +from hub_core.message_identity import resolve_message_reference +from hub_core.models.message_identity_alias import MessageIdentityAlias + +router = APIRouter(prefix="/messages", tags=["messages"]) + +UNATTRIBUTED_BROADCAST_READ_KEY = "rest_api:PATCH /messages/{id}/read broadcast-without-reader" +UNATTRIBUTED_BROADCAST_READ_REPLACEMENT = "PATCH /messages/{id}/read?reader=" +UNATTRIBUTED_BROADCAST_WARNING = '299 - "broadcast mark-read needs ?reader="' + + +async def _reader_values(session: AsyncSession, reader: str) -> tuple[str, tuple[str, ...]]: + """Canonical reader slug plus every historical slug it answers to.""" + resolution = await resolve_repository_slug(session, reader, required=False) + if resolution is None: + return reader, (reader,) + return resolution.canonical_slug, tuple(resolution.slug_values) + + +async def _read_view( + session: AsyncSession, + message: AgentMessage, + reader_values: tuple[str, ...] | None, + reader: str | None, +) -> MessageRead: + view = MessageRead.model_validate(message) + if reader_values is None or not is_broadcast(message): + return view + state = (await receipts_for(session, [message.id], reader_values)).get(message.id, {}) + return view.model_copy( + update={ + "reader": reader, + "delivered_at": state.get("delivered_at"), + "read_at": state.get("read_at"), + "acknowledged_at": state.get("acknowledged_at"), + } + ) + + +async def _get_message(reference: str, session: AsyncSession) -> AgentMessage: + message_id = await resolve_message_reference( + session, reference, alias_model=MessageIdentityAlias + ) + if message_id is None: + raise HTTPException( + status_code=404, detail=f"Message reference {reference!r} not found" + ) + message = await session.get(AgentMessage, message_id) + if message is None: + raise HTTPException( + status_code=404, detail=f"Message reference {reference!r} not found" + ) + return message + + +@router.post("/", response_model=MessageRead, status_code=status.HTTP_201_CREATED) +async def send_message( + body: MessageCreate, + session: AsyncSession = Depends(get_session), +) -> AgentMessage: + if body.thread_id and await session.get(AgentMessage, body.thread_id) is None: + raise HTTPException(status_code=404, detail=f"Thread root {body.thread_id} not found") + payload = body.model_dump() + payload["from_agent"] = await canonicalize_repository_slug(session, body.from_agent) + payload["to_agent"] = await canonicalize_repository_slug(session, body.to_agent) + if payload["to_agent"] == BROADCAST: + kind = body.kind or "news" + if kind == "message": + kind = "news" + if kind not in MESSAGE_KINDS: + raise HTTPException( + status_code=422, + detail=f"kind must be one of {', '.join(MESSAGE_KINDS[1:])} for broadcasts", + ) + payload["kind"] = kind + if kind == "news" and body.expires_at is None: + payload["expires_at"] = utcnow() + NEWS_DEFAULT_TTL + if body.supersedes_id is not None: + predecessor = await session.get(AgentMessage, body.supersedes_id) + if predecessor is None or not is_broadcast(predecessor): + raise HTTPException( + status_code=404, + detail=f"Superseded broadcast {body.supersedes_id} not found", + ) + if predecessor.archived_at is None: + predecessor.archived_at = utcnow() + else: + if body.kind not in (None, "message"): + raise HTTPException( + status_code=422, detail="kind news/standing is only valid for broadcasts" + ) + if body.expires_at is not None or body.supersedes_id is not None: + raise HTTPException( + status_code=422, + detail="expires_at and supersedes_id are only valid for broadcasts", + ) + payload["kind"] = "message" + message = AgentMessage(**payload) + session.add(message) + await session.commit() + await session.refresh(message) + return message + + +@router.get("/", response_model=list[MessageRead]) +async def list_messages( + to_agent: str | None = None, + from_agent: str | None = None, + unread_only: bool = False, + limit: int = 50, + offset: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +) -> list[MessageRead]: + now = utcnow() + query = select(AgentMessage).where(AgentMessage.archived_at.is_(None)) + reader: str | None = None + reader_values: tuple[str, ...] | None = None + if to_agent: + reader, reader_values = await _reader_values(session, to_agent) + direct = AgentMessage.to_agent.in_(reader_values) + if unread_only: + query = query.where( + or_( + and_(direct, AgentMessage.read_at.is_(None)), + broadcast_unread_clause(reader_values, now), + ) + ) + else: + query = query.where( + or_( + direct, + and_( + AgentMessage.to_agent == BROADCAST, + or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now), + ), + ) + ) + elif unread_only: + query = query.where(AgentMessage.read_at.is_(None)) + if from_agent: + resolution = await resolve_repository_slug(session, from_agent, required=False) + values = resolution.slug_values if resolution else (from_agent,) + query = query.where(AgentMessage.from_agent.in_(values)) + result = await session.execute( + # ``id`` breaks created_at ties so offset pages are stable (STATE-WP-0094-T03). + query.order_by(AgentMessage.created_at.desc(), AgentMessage.id.desc()) + .offset(offset) + .limit(limit) + ) + messages = list(result.scalars().all()) + if reader_values is None or reader is None: + return [MessageRead.model_validate(m) for m in messages] + + broadcast_ids = [m.id for m in messages if is_broadcast(m)] + if unread_only and broadcast_ids and reader != BROADCAST: + # D3 (founder-approved 2026-09-22): the orientation inbox call records + # an idempotent delivery receipt for each broadcast it returns. + await upsert_receipt(session, broadcast_ids, reader, delivered=True, at=now) + await session.commit() + receipts = await receipts_for(session, broadcast_ids, reader_values) + views = [] + for message in messages: + view = MessageRead.model_validate(message) + if is_broadcast(message): + state = receipts.get(message.id, {}) + view = view.model_copy( + update={ + "reader": reader, + "delivered_at": state.get("delivered_at"), + "read_at": state.get("read_at"), + "acknowledged_at": state.get("acknowledged_at"), + } + ) + views.append(view) + return views + + +@router.get("/notices", response_model=list[NoticeStatus]) +async def list_notices( + session: AsyncSession = Depends(get_session), +) -> list[NoticeStatus]: + """Live standing notices with acknowledged / delivered-only / unreached repos.""" + return await notice_statuses(session) + + +@router.get("/thread/{thread_id}", response_model=list[MessageRead]) +async def get_thread( + thread_id: str, + session: AsyncSession = Depends(get_session), +) -> list[AgentMessage]: + resolved = await resolve_message_reference( + session, thread_id, alias_model=MessageIdentityAlias + ) + if resolved is None: + raise HTTPException( + status_code=404, detail=f"Message reference {thread_id!r} not found" + ) + result = await session.execute( + select(AgentMessage) + .where(or_(AgentMessage.id == resolved, AgentMessage.thread_id == resolved)) + .order_by(AgentMessage.created_at) + ) + return list(result.scalars().all()) + + +@router.patch("/{message_id}/read", response_model=MessageRead) +async def mark_read( + message_id: str, + request: Request, + response: Response, + reader: str | None = None, + ack: bool = False, + body: MessageMarkRead | None = Body(default=None), + session: AsyncSession = Depends(get_session), +) -> AgentMessage | MessageRead: + message = await _get_message(message_id, session) + if not is_broadcast(message): + if message.read_at is None: + message.read_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(message) + return message + + reader = reader or (body.reader if body else None) + ack = ack or bool(body and body.ack) + if not reader: + view = MessageRead.model_validate(message) + # D2: an unattributed mark-read never hides a broadcast from anyone. + response.headers["Deprecation"] = "true" + response.headers["Warning"] = UNATTRIBUTED_BROADCAST_WARNING + response.headers["X-StateHub-Replacement"] = UNATTRIBUTED_BROADCAST_READ_REPLACEMENT + try: + await record_legacy_usage( + session, + interface_key=UNATTRIBUTED_BROADCAST_READ_KEY, + interface_kind="rest_api", + replacement_ref=UNATTRIBUTED_BROADCAST_READ_REPLACEMENT, + owner_component="state-hub.api", + replacement_verified=True, + identity=identity_from_request(request), + ) + except Exception: + await session.rollback() + return view + + canonical, values = await _reader_values(session, reader) + await upsert_receipt(session, [message.id], canonical, read=True, acknowledged=ack) + await session.commit() + return await _read_view(session, message, values, canonical) + + +@router.post("/{message_id}/ack", response_model=MessageRead) +async def acknowledge_message( + message_id: str, + body: MessageAck, + session: AsyncSession = Depends(get_session), +) -> MessageRead: + """Acknowledge a broadcast (clears a standing notice for that agent).""" + message = await _get_message(message_id, session) + if not is_broadcast(message): + raise HTTPException( + status_code=400, + detail="ack applies to broadcasts only; use PATCH /messages/{id}/read", + ) + canonical, values = await _reader_values(session, body.agent) + await upsert_receipt(session, [message.id], canonical, acknowledged=True) + await session.commit() + return await _read_view(session, message, values, canonical) + + +@router.patch("/{message_id}/archive", response_model=MessageRead) +async def archive_message( + message_id: str, + session: AsyncSession = Depends(get_session), +) -> AgentMessage: + message = await _get_message(message_id, session) + # First write wins (inbox feed contract §6): re-archiving keeps archived_at. + if message.archived_at is None: + message.archived_at = datetime.now(timezone.utc) + # Broadcast archive is a global withdrawal; it no longer stamps read_at. + if message.read_at is None and not is_broadcast(message): + message.read_at = message.archived_at + await session.commit() + await session.refresh(message) + return message + + +@router.post( + "/{message_id}/reply", + response_model=MessageRead, + status_code=status.HTTP_201_CREATED, +) +async def reply_to_message( + message_id: str, + body: MessageReply, + session: AsyncSession = Depends(get_session), +) -> AgentMessage: + original = await _get_message(message_id, session) + replier = await canonicalize_repository_slug(session, body.from_agent) + if is_broadcast(original): + await upsert_receipt(session, [original.id], replier, read=True) + elif original.read_at is None: + original.read_at = datetime.now(timezone.utc) + reply = AgentMessage( + from_agent=replier, + to_agent=await canonicalize_repository_slug(session, original.from_agent), + subject=f"Re: {original.subject}", + body=body.body, + thread_id=original.thread_id or original.id, + ) + session.add(reply) + await session.commit() + await session.refresh(reply) + return reply + + +__all__ = ["router"] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/ops_runs.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/ops_runs.py new file mode 100644 index 00000000..7f0d2835 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/ops_runs.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from api.schemas.ops_run import OpsRunProjection +from api.services.ops_run_projection import get_ops_run_projection + + +router = APIRouter(prefix="/ops-runs", tags=["ops-runs"]) + + +@router.get("/summary", response_model=OpsRunProjection) +async def get_ops_runs_summary(refresh: bool = False) -> OpsRunProjection: + """Project activity-core queue health; State Hub never claims ops runs.""" + return await get_ops_run_projection(refresh=refresh) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/policy.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/policy.py new file mode 100644 index 00000000..3da152e7 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/policy.py @@ -0,0 +1,34 @@ +import re +from pathlib import Path + +from fastapi import HTTPException +from hub_core.routers.policy import create_policy_router +from hub_core.schemas.policy import PolicyRead + +POLICY_DIR = Path(__file__).parent.parent.parent / "policies" +_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") + + +def _policy_path(name: str) -> Path: + if not _VALID_NAME.match(name): + raise HTTPException(status_code=400, detail="Invalid policy name") + path = POLICY_DIR / f"{name}.md" + if not path.exists(): + raise HTTPException(status_code=404, detail=f"Policy '{name}' not found") + return path + + +def _load_policy(name: str) -> PolicyRead: + path = _policy_path(name) + return PolicyRead(name=name, content=path.read_text()) + + +def _update_policy(name: str, content: str) -> PolicyRead: + path = _policy_path(name) + path.write_text(content) + return PolicyRead(name=name, content=content) + + +router = create_policy_router(_load_policy, update_policy=_update_policy) + +__all__ = ["router"] diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/progress.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/progress.py new file mode 100644 index 00000000..f959d504 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/progress.py @@ -0,0 +1,58 @@ +from typing import Any + +from fastapi import Request, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.progress_event import ProgressEvent +from api.schemas.progress_event import ProgressEventCreate, ProgressEventRead +from api.services.legacy_compat import meter_legacy_body_field, meter_legacy_query_param +from hub_core.routers.progress import create_progress_router + + +async def _meter_progress_workstream_id_query( + session: AsyncSession, + request: Request, + response: Response, +) -> None: + await meter_legacy_query_param( + session=session, + request=request, + response=response, + method="GET", + route="/progress/", + replacement_ref="/progress/?workplan_id=", + ) + + +async def _meter_progress_workstream_id_body( + session: AsyncSession, + request: Request, + response: Response, +) -> None: + await meter_legacy_body_field( + session=session, + request=request, + response=response, + method="POST", + route="/progress/", + field="workstream_id", + replacement_ref="POST /progress/ with workplan_id", + ) + + +def _progress_body_uses_legacy_workstream_id(body: Any) -> bool: + return bool(getattr(body, "used_legacy_workstream_id", False)) + + +router = create_progress_router( + get_session, + progress_model=ProgressEvent, + progress_create_schema=ProgressEventCreate, + progress_read_schema=ProgressEventRead, + meter_legacy_workstream_id=_meter_progress_workstream_id_query, + progress_body_uses_legacy_workstream_id=_progress_body_uses_legacy_workstream_id, + meter_legacy_workstream_id_body=_meter_progress_workstream_id_body, +) + +__all__ = ["router"] \ No newline at end of file diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/recently_on_scope.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/recently_on_scope.py new file mode 100644 index 00000000..c62e9c63 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/recently_on_scope.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.responses import PlainTextResponse + +from api.database import get_session +from api.schemas.recently_on_scope import ( + RecentlyOnScopeGenerate, + RecentlyOnScopeGeneratedReport, + RecentlyOnScopeHourlyGenerate, + RecentlyOnScopeHourlyRun, + RecentlyOnScopeReportMetadata, +) +from api.services.markitect_templates import MarkitectRenderError, MarkitectUnavailable +from api.services.recently_on_scope import ( + generate_report, + generate_hourly_reports, + list_reports, + read_report, + resolve_window, +) + +router = APIRouter(prefix="/domains/{slug}/recently-on-scope", tags=["recently-on-scope"]) +hourly_router = APIRouter(prefix="/recently-on-scope", tags=["recently-on-scope"]) + + +@hourly_router.post("/hourly", response_model=RecentlyOnScopeHourlyRun, status_code=status.HTTP_201_CREATED) +async def generate_hourly_recently_on_scope( + body: RecentlyOnScopeHourlyGenerate, + session: AsyncSession = Depends(get_session), +) -> RecentlyOnScopeHourlyRun: + try: + window = resolve_window(body.range, body.since, body.until) + return await generate_hourly_reports( + session, + window, + active_only=body.active_only, + include_attention=body.include_attention, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + + +@router.post("/", response_model=RecentlyOnScopeGeneratedReport, status_code=status.HTTP_201_CREATED) +async def generate_recently_on_scope( + slug: str, + body: RecentlyOnScopeGenerate, + session: AsyncSession = Depends(get_session), +) -> RecentlyOnScopeGeneratedReport: + try: + window = resolve_window(body.range, body.since, body.until) + metadata, markdown = await generate_report(session, slug, window) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + except MarkitectUnavailable as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except MarkitectRenderError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return RecentlyOnScopeGeneratedReport(**metadata.model_dump(), markdown=markdown) + + +@router.get("/", response_model=list[RecentlyOnScopeReportMetadata]) +async def list_recently_on_scope(slug: str) -> list[RecentlyOnScopeReportMetadata]: + return list_reports(slug) + + +@router.get("/{report_id}", response_class=PlainTextResponse) +async def get_recently_on_scope_report(slug: str, report_id: str) -> PlainTextResponse: + try: + markdown = read_report(slug, report_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Report not found") from exc + return PlainTextResponse(markdown, media_type="text/markdown") diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/reconciliation.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/reconciliation.py new file mode 100644 index 00000000..b9537690 --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/reconciliation.py @@ -0,0 +1,376 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.agent_message import AgentMessage +from api.models.managed_repo import ManagedRepo +from api.models.task import Task +from api.models.task import TaskStatus +from api.models.workplan import Workplan +from api.schemas.reconciliation import StateChangeRequest, StateChangeResponse +from api.services.lifecycle import ( + should_activate_parent_for_task_start, + status_value, + transition_task_status, + transition_workplan_status, +) +from api.task_status import TERMINAL_TASK_STATUSES +from api.services.reconciliation import ( + ReconciliationClass, + StateChangeClassification, + classify_task_status_change, + classify_workplan_status_change, +) +from api.services.workplan_files import ( + find_workplan_for_workplan, + patch_task_status, + patch_workplan_status, + resolve_repo_path, + task_block_status, + task_block_linked, + workplan_status, +) +from api.workplan_status import normalize_workplan_status + +router = APIRouter(prefix="/reconciliation", tags=["reconciliation"]) + + +def _bool_or_default(value: bool | None, default: bool) -> bool: + return default if value is None else value + + +def _conflict(reason: str, follow_up: str) -> StateChangeClassification: + return StateChangeClassification( + ReconciliationClass.DEFERRED, + reason, + follow_up, + ) + + +async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool: + result = await session.execute(select(Task.status).where(Task.workplan_id == workstream_id)) + statuses = [status_value(row[0]) for row in result.all()] + return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses) + + +def _deferred_message( + *, + body: StateChangeRequest, + current_status: str, + target_status: str, + classification: ReconciliationClass, + reason: str, + follow_up: str, + workplan_path: str | None, + conflict: bool = False, +) -> AgentMessage: + subject = f"Reconcile {body.target_type} state change: {current_status} -> {target_status}" + lines = [ + "A UI-originated state change could not be written through immediately.", + "", + f"target_type: {body.target_type}", + f"target_id: {body.target_id}", + f"actor: {body.actor}", + f"intent: {body.intent or ''}", + f"expected_current_status: {body.expected_current_status or ''}", + f"current_status: {current_status}", + f"target_status: {target_status}", + f"reconciliation_class: {classification.value}", + f"conflict: {str(conflict).lower()}", + f"reason: {reason}", + f"follow_up: {follow_up}", + f"workplan_path: {workplan_path or ''}", + ] + return AgentMessage( + from_agent=body.actor, + to_agent="state-hub", + subject=subject, + body="\n".join(lines), + ) + + +@router.post("/state-change", response_model=StateChangeResponse) +async def classify_state_change( + body: StateChangeRequest, + session: AsyncSession = Depends(get_session), +) -> StateChangeResponse: + if body.target_type == "workstream": + ws = await session.get(Workplan, body.target_id) + if ws is None: + raise HTTPException(status_code=404, detail="Workplan not found") + + repo = await session.get(ManagedRepo, ws.repo_id) if ws.repo_id else None + repo_path = resolve_repo_path(repo) + workplan_ref = find_workplan_for_workplan(repo, ws.id) if repo_path else None + actual_file_backed = workplan_ref is not None + actual_archived_file = bool(workplan_ref and workplan_ref.archived) + file_backed = ( + actual_file_backed + if body.apply + else _bool_or_default(body.file_backed, actual_file_backed) + ) + archived_file = ( + actual_archived_file + if body.apply + else _bool_or_default(body.archived_file, actual_archived_file) + ) + tasks_terminal = ( + body.tasks_terminal + if body.tasks_terminal is not None + else await _workstream_tasks_terminal(session, ws.id) + ) + current_status = normalize_workplan_status(ws.status) + target_status = normalize_workplan_status(body.target_status) + classification = classify_workplan_status_change( + current_status=current_status, + target_status=target_status, + file_backed=file_backed, + archived_file=archived_file, + tasks_terminal=tasks_terminal, + ) + write_result = "not_attempted" + reconciliation_record_id = None + conflict = False + if body.apply: + expected_status = ( + normalize_workplan_status(body.expected_current_status) + if body.expected_current_status is not None + else None + ) + if expected_status is not None and expected_status != current_status: + classification = _conflict( + f"cached workstream status changed from expected {expected_status!r} to {current_status!r}", + "refresh the dashboard and retry the state change if it is still intended", + ) + conflict = True + elif repo is not None and repo_path is None: + classification = _conflict( + "repo host path is unavailable for this State Hub host", + "register a host path for this machine or retry from a host with the repo checkout", + ) + conflict = True + elif classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH and workplan_ref: + file_status = normalize_workplan_status(workplan_status(workplan_ref.path)) + if file_status and file_status != current_status: + classification = _conflict( + f"workplan file status {file_status!r} differs from cached DB status {current_status!r}", + "run consistency repair or refresh State Hub from files before retrying", + ) + conflict = True + else: + try: + patch_workplan_status(workplan_ref.path, target_status) + patched_status = normalize_workplan_status(workplan_status(workplan_ref.path)) + except OSError as exc: + classification = _conflict( + f"workplan file write failed: {exc}", + "fix repo file access and retry the reconciliation", + ) + conflict = True + else: + if patched_status != target_status: + classification = _conflict( + f"workplan file status could not be patched to {target_status!r}", + "inspect the workplan frontmatter format before retrying", + ) + conflict = True + else: + transition_workplan_status(ws, target_status) + await session.commit() + write_result = "applied" + + if write_result != "applied": + msg = _deferred_message( + body=body, + current_status=current_status, + target_status=target_status, + classification=classification.reconciliation_class, + reason=classification.reason, + follow_up=classification.follow_up, + workplan_path=workplan_ref.relative_path if workplan_ref else None, + conflict=conflict, + ) + session.add(msg) + await session.commit() + await session.refresh(msg) + reconciliation_record_id = msg.id + write_result = "not_applicable" + return StateChangeResponse( + target_type=body.target_type, + target_id=body.target_id, + actor=body.actor, + intent=body.intent, + current_status=current_status, + target_status=target_status, + file_backed=file_backed, + archived_file=archived_file, + tasks_terminal=tasks_terminal, + reconciliation_class=classification.reconciliation_class, + reason=classification.reason, + follow_up=classification.follow_up, + write_through_result=write_result, + workplan_path=workplan_ref.relative_path if workplan_ref else None, + reconciliation_record_id=reconciliation_record_id, + conflict=conflict, + ) + + task = await session.get(Task, body.target_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + + ws = await session.get(Workplan, task.workplan_id) + repo = await session.get(ManagedRepo, ws.repo_id) if ws and ws.repo_id else None + repo_path = resolve_repo_path(repo) + workplan_ref = find_workplan_for_workplan(repo, ws.id) if ws and repo_path else None + actual_file_backed = workplan_ref is not None + actual_archived_file = bool(workplan_ref and workplan_ref.archived) + file_backed = ( + actual_file_backed + if body.apply + else _bool_or_default(body.file_backed, actual_file_backed) + ) + archived_file = ( + actual_archived_file + if body.apply + else _bool_or_default(body.archived_file, actual_archived_file) + ) + actual_task_linked = bool(workplan_ref and task_block_linked(workplan_ref.path, task.id)) + task_linked = ( + actual_task_linked + if body.apply + else _bool_or_default(body.task_linked, actual_task_linked) + ) + current_status = status_value(task.status) + target_status = status_value(body.target_status) + classification = classify_task_status_change( + current_status=current_status, + target_status=target_status, + file_backed=file_backed, + archived_file=archived_file, + task_linked=task_linked, + blocking_reason=body.blocking_reason, + ) + write_result = "not_attempted" + reconciliation_record_id = None + conflict = False + if body.apply: + expected_status = ( + status_value(body.expected_current_status) + if body.expected_current_status is not None + else None + ) + if expected_status is not None and expected_status != current_status: + classification = _conflict( + f"cached task status changed from expected {expected_status!r} to {current_status!r}", + "refresh the dashboard and retry the state change if it is still intended", + ) + conflict = True + elif repo is not None and repo_path is None: + classification = _conflict( + "repo host path is unavailable for this State Hub host", + "register a host path for this machine or retry from a host with the repo checkout", + ) + conflict = True + elif ( + classification.reconciliation_class == ReconciliationClass.WRITE_THROUGH + and workplan_ref + and actual_task_linked + ): + file_status = status_value(task_block_status(workplan_ref.path, task.id)) + if file_status and file_status != current_status: + classification = _conflict( + f"workplan task status {file_status!r} differs from cached DB status {current_status!r}", + "run consistency repair or refresh State Hub from files before retrying", + ) + conflict = True + else: + original_text = None + parent_will_activate = should_activate_parent_for_task_start( + previous_task_status=current_status, + new_task_status=target_status, + parent_workplan_status=ws.status if ws else None, + ) + try: + original_text = workplan_ref.path.read_text(encoding="utf-8") + patch_task_status(workplan_ref.path, task.id, target_status) + patched_status = status_value(task_block_status(workplan_ref.path, task.id)) + if parent_will_activate: + patch_workplan_status(workplan_ref.path, "active") + parent_status = normalize_workplan_status(workplan_status(workplan_ref.path)) + if parent_status != "active": + if original_text is not None: + workplan_ref.path.write_text(original_text, encoding="utf-8") + classification = _conflict( + "parent workplan status could not be patched to 'active'", + "inspect the workplan frontmatter format before retrying", + ) + conflict = True + except OSError as exc: + if original_text is not None: + workplan_ref.path.write_text(original_text, encoding="utf-8") + classification = _conflict( + f"workplan task write failed: {exc}", + "fix repo file access and retry the reconciliation", + ) + conflict = True + else: + if conflict: + pass + elif patched_status != target_status: + if original_text is not None: + workplan_ref.path.write_text(original_text, encoding="utf-8") + classification = _conflict( + f"workplan task block could not be patched to {target_status!r}", + "inspect the task block format before retrying", + ) + conflict = True + else: + transition = transition_task_status( + task, + target_status, + parent_workstream=ws, + previous_task_status=current_status, + status_coercer=TaskStatus, + ) + if body.blocking_reason is not None: + task.blocking_reason = body.blocking_reason + await session.commit() + write_result = "applied" + + if write_result != "applied": + msg = _deferred_message( + body=body, + current_status=current_status, + target_status=target_status, + classification=classification.reconciliation_class, + reason=classification.reason, + follow_up=classification.follow_up, + workplan_path=workplan_ref.relative_path if workplan_ref else None, + conflict=conflict, + ) + session.add(msg) + await session.commit() + await session.refresh(msg) + reconciliation_record_id = msg.id + write_result = "not_applicable" + return StateChangeResponse( + target_type=body.target_type, + target_id=body.target_id, + actor=body.actor, + intent=body.intent, + current_status=current_status, + target_status=target_status, + file_backed=file_backed, + archived_file=archived_file, + task_linked=task_linked, + reconciliation_class=classification.reconciliation_class, + reason=classification.reason, + follow_up=classification.follow_up, + write_through_result=write_result, + workplan_path=workplan_ref.relative_path if workplan_ref else None, + reconciliation_record_id=reconciliation_record_id, + conflict=conflict, + ) diff --git a/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repo_goals.py b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repo_goals.py new file mode 100644 index 00000000..601684ec --- /dev/null +++ b/docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/state-hub/api/routers/repo_goals.py @@ -0,0 +1,77 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.managed_repo import ManagedRepo +from api.models.repo_goal import RepoGoal, RepoGoalStatus +from api.schemas.repo_goal import RepoGoalCreate, RepoGoalRead, RepoGoalUpdate +from api.services.repository_aliases import resolve_repository_slug + +router = APIRouter(prefix="/repo-goals", tags=["repo-goals"]) + + +async def _resolve_repo(repo_slug: str, session: AsyncSession) -> ManagedRepo: + resolution = await resolve_repository_slug(session, repo_slug) + return resolution.repo + + +@router.get("/", response_model=list[RepoGoalRead]) +async def list_repo_goals( + repo_slug: str | None = None, + domain_goal_id: uuid.UUID | None = None, + status: str | None = None, + session: AsyncSession = Depends(get_session), +) -> list[RepoGoal]: + q = select(RepoGoal) + if repo_slug: + repo = await _resolve_repo(repo_slug, session) + q = q.where(RepoGoal.repo_id == repo.id) + if domain_goal_id: + q = q.where(RepoGoal.domain_goal_id == domain_goal_id) + if status: + q = q.where(RepoGoal.status == status) + q = q.order_by(RepoGoal.priority.asc(), RepoGoal.created_at.asc()) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.post("/", response_model=RepoGoalRead, status_code=status.HTTP_201_CREATED) +async def create_repo_goal( + body: RepoGoalCreate, + session: AsyncSession = Depends(get_session), +) -> RepoGoal: + goal = RepoGoal(**body.model_dump()) + session.add(goal) + await session.commit() + await session.refresh(goal) + return goal + + +@router.get("/{goal_id}", response_model=RepoGoalRead) +async def get_repo_goal( + goal_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> RepoGoal: + goal = await session.get(RepoGoal, goal_id) + if goal is None: + raise HTTPException(status_code=404, detail="Repo goal not found") + return goal + + +@router.patch("/{goal_id}", response_model=RepoGoalRead) +async def update_repo_goal( + goal_id: uuid.UUID, + body: RepoGoalUpdate, + session: AsyncSession = Depends(get_session), +) -> RepoGoal: + goal = await session.get(RepoGoal, goal_id) + if goal is None: + raise HTTPException(status_code=404, detail="Repo goal not found") + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(goal, field, value) + await session.commit() + await session.refresh(goal) + return goal