docs: preserve reviewed NetKingdom session artifacts
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Reject generated-only commits / generated-commit-guard (push) Successful in 9s

This commit is contained in:
codex 2026-11-01 22:56:19 +01:00
parent 3402090e5a
commit bd041baf9b
50 changed files with 6347 additions and 0 deletions

View file

@ -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")

View file

@ -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")

View file

@ -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"),
)

View file

@ -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"
)

View file

@ -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 ""

View file

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

View file

@ -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

View file

@ -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 ""

View file

@ -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"
)

View file

@ -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",
),
)

View file

@ -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"
)

View file

@ -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"
)

View file

@ -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")

View file

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

View file

@ -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:<agent>" (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"
)

View file

@ -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 ""

View file

@ -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

View file

@ -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

View file

@ -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")

View file

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

View file

@ -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"
)

View file

@ -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:<repo-slug>`` 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

View file

@ -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

View file

@ -0,0 +1,6 @@
"""Backward-compatibility shim — prefer ``api.models.workplan``."""
from api.models.workplan import Workplan
Workstream = Workplan
__all__ = ["Workstream", "Workplan"]

View file

@ -0,0 +1,6 @@
"""Backward-compatibility shim — prefer ``api.models.workplan_dependency``."""
from api.models.workplan_dependency import WorkplanDependency
WorkstreamDependency = WorkplanDependency
__all__ = ["WorkstreamDependency", "WorkplanDependency"]

View file

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

View file

@ -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,
)
)

View file

@ -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

View file

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

View file

@ -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=<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
)

View file

@ -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

View file

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

View file

@ -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=<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()

View file

@ -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

View file

@ -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()]

View file

@ -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

View file

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

View file

@ -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

View file

@ -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

View file

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

View file

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

View file

@ -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,
)

View file

@ -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=<agent>"
UNATTRIBUTED_BROADCAST_WARNING = '299 - "broadcast mark-read needs ?reader=<agent>"'
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"]

View file

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

View file

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

View file

@ -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=<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"]

View file

@ -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")

View file

@ -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,
)

View file

@ -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