generated from coulomb/repo-seed
Compare commits
5 Commits
f48206424e
...
ce82ada0fa
| Author | SHA1 | Date | |
|---|---|---|---|
| ce82ada0fa | |||
| f14c225dd9 | |||
| d68de69fe6 | |||
| 77689fbfb2 | |||
| 0192dc786f |
@@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse
|
||||
from api.database import engine
|
||||
from api.events import shutdown_publisher
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
|
||||
from api.routers import token_events
|
||||
from api.routers import interface_changes
|
||||
from api.routers import flows
|
||||
@@ -120,6 +120,7 @@ app.include_router(sbom.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(capability_requests.router)
|
||||
app.include_router(tpsc.router)
|
||||
app.include_router(services.router)
|
||||
app.include_router(token_events.router)
|
||||
app.include_router(interface_changes.router)
|
||||
app.include_router(flows.router)
|
||||
|
||||
@@ -18,6 +18,13 @@ from api.models.agent_message import AgentMessage
|
||||
from api.models.capability_catalog import CapabilityCatalog
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||
from api.models.service_catalog import (
|
||||
ServiceCatalog,
|
||||
ServiceThirdParty,
|
||||
ServiceFirstParty,
|
||||
ServiceCloud,
|
||||
ServiceSelfHosted,
|
||||
)
|
||||
from api.models.doi_cache import DOICache
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.interface_change import InterfaceChange
|
||||
@@ -46,6 +53,8 @@ __all__ = [
|
||||
"CapabilityCatalog",
|
||||
"CapabilityRequest",
|
||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||
"ServiceCatalog", "ServiceThirdParty", "ServiceFirstParty",
|
||||
"ServiceCloud", "ServiceSelfHosted",
|
||||
"DOICache",
|
||||
"TokenEvent",
|
||||
"InterfaceChange",
|
||||
|
||||
121
api/models/service_catalog.py
Normal file
121
api/models/service_catalog.py
Normal 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")
|
||||
143
api/routers/services.py
Normal file
143
api/routers/services.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Two-dimension service catalog API (STATE-WP-0062).
|
||||
|
||||
Read/write surface over service_catalog and its per-dimension extension tables.
|
||||
The four service classes are queried by combining the hosting_type and
|
||||
development_type filters. The legacy /tpsc routes remain for third-party
|
||||
dependency snapshots; this router is the source of truth for the catalog itself.
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.service_catalog import (
|
||||
ServiceCatalog,
|
||||
ServiceCloud,
|
||||
ServiceFirstParty,
|
||||
ServiceSelfHosted,
|
||||
ServiceThirdParty,
|
||||
)
|
||||
from api.schemas.service import ServiceCatalogRead, ServiceUpsert
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["services"])
|
||||
|
||||
_HOSTING = {"self_hosted", "cloud_hosted"}
|
||||
_DEVELOPMENT = {"first_party", "third_party"}
|
||||
|
||||
_WITH_EXTENSIONS = (
|
||||
selectinload(ServiceCatalog.third_party),
|
||||
selectinload(ServiceCatalog.first_party),
|
||||
selectinload(ServiceCatalog.cloud),
|
||||
selectinload(ServiceCatalog.self_hosted),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=list[ServiceCatalogRead])
|
||||
async def list_services(
|
||||
hosting_type: str | None = None,
|
||||
development_type: str | None = None,
|
||||
maturity_level: int | None = None,
|
||||
status: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[ServiceCatalog]:
|
||||
q = select(ServiceCatalog).options(*_WITH_EXTENSIONS)
|
||||
if hosting_type:
|
||||
q = q.where(ServiceCatalog.hosting_type == hosting_type)
|
||||
if development_type:
|
||||
q = q.where(ServiceCatalog.development_type == development_type)
|
||||
if maturity_level is not None:
|
||||
q = q.where(ServiceCatalog.maturity_level == maturity_level)
|
||||
if status:
|
||||
q = q.where(ServiceCatalog.status == status)
|
||||
q = q.order_by(ServiceCatalog.name.asc())
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/{slug}", response_model=ServiceCatalogRead)
|
||||
async def get_service(
|
||||
slug: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ServiceCatalog:
|
||||
svc = await _resolve(slug, session)
|
||||
if svc is None:
|
||||
raise HTTPException(status_code=404, detail=f"Service '{slug}' not found")
|
||||
return svc
|
||||
|
||||
|
||||
@router.post("/catalog", response_model=ServiceCatalogRead, status_code=status.HTTP_201_CREATED)
|
||||
async def upsert_service(
|
||||
body: ServiceUpsert,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ServiceCatalog:
|
||||
if body.hosting_type not in _HOSTING:
|
||||
raise HTTPException(status_code=422, detail=f"hosting_type must be one of {sorted(_HOSTING)}")
|
||||
if body.development_type not in _DEVELOPMENT:
|
||||
raise HTTPException(status_code=422, detail=f"development_type must be one of {sorted(_DEVELOPMENT)}")
|
||||
|
||||
svc = await _resolve(body.slug, session)
|
||||
if svc is None:
|
||||
svc = ServiceCatalog(slug=body.slug)
|
||||
session.add(svc)
|
||||
|
||||
for field in ("name", "owner_or_provider", "category", "description",
|
||||
"website_url", "status", "hosting_type", "development_type",
|
||||
"maturity_level"):
|
||||
setattr(svc, field, getattr(body, field))
|
||||
|
||||
await _apply_extensions(svc, body, session)
|
||||
await session.commit()
|
||||
|
||||
return await _resolve(body.slug, session)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _resolve(slug: str, session: AsyncSession) -> ServiceCatalog | None:
|
||||
result = await session.execute(
|
||||
select(ServiceCatalog).where(ServiceCatalog.slug == slug).options(*_WITH_EXTENSIONS)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _upsert_ext(model, service_id: uuid.UUID, data: dict, session: AsyncSession) -> None:
|
||||
"""Create or update a 1:1 extension row keyed by service_id.
|
||||
|
||||
Fetched via session.get (not the relationship attribute) so we never trigger
|
||||
a lazy relationship load on a freshly-created core row in async context.
|
||||
"""
|
||||
current = await session.get(model, service_id)
|
||||
if current is None:
|
||||
current = model(service_id=service_id)
|
||||
session.add(current)
|
||||
for k, v in data.items():
|
||||
setattr(current, k, v)
|
||||
|
||||
|
||||
async def _apply_extensions(svc: ServiceCatalog, body: ServiceUpsert, session: AsyncSession) -> None:
|
||||
# Ensure svc.id is available for new rows.
|
||||
await session.flush()
|
||||
|
||||
if body.third_party is not None:
|
||||
await _upsert_ext(ServiceThirdParty, svc.id, body.third_party.model_dump(), session)
|
||||
if body.cloud is not None:
|
||||
await _upsert_ext(ServiceCloud, svc.id, body.cloud.model_dump(), session)
|
||||
if body.self_hosted is not None:
|
||||
await _upsert_ext(ServiceSelfHosted, svc.id, body.self_hosted.model_dump(), session)
|
||||
if body.first_party is not None:
|
||||
data = body.first_party.model_dump(exclude={"repo_slug"})
|
||||
if body.first_party.repo_slug and not data.get("repo_id"):
|
||||
repo = (await session.execute(
|
||||
select(ManagedRepo).where(ManagedRepo.slug == body.first_party.repo_slug)
|
||||
)).scalar_one_or_none()
|
||||
if repo is None:
|
||||
raise HTTPException(status_code=404, detail=f"Repo '{body.first_party.repo_slug}' not found")
|
||||
data["repo_id"] = repo.id
|
||||
await _upsert_ext(ServiceFirstParty, svc.id, data, session)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
116
api/schemas/service.py
Normal file
116
api/schemas/service.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Schemas for the two-dimension service catalog (STATE-WP-0062)."""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
# ── Extension read models ────────────────────────────────────────────────────
|
||||
|
||||
class ServiceThirdPartyRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
pricing_model: str
|
||||
upstream_packages: list | None = None
|
||||
upstream_contacts: list | None = None
|
||||
source_url: str | None = None
|
||||
support_url: str | None = None
|
||||
license: str | None = None
|
||||
|
||||
|
||||
class ServiceFirstPartyRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
repo_id: uuid.UUID | None = None
|
||||
owning_domain: str | None = None
|
||||
|
||||
|
||||
class ServiceCloudRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
gdpr_maturity: str
|
||||
gdpr_notes: str | None = None
|
||||
dpa_available: bool
|
||||
tos_url: str | None = None
|
||||
privacy_policy_url: str | None = None
|
||||
data_processing_regions: list | None = None
|
||||
data_retention_notes: str | None = None
|
||||
|
||||
|
||||
class ServiceSelfHostedRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
helix_instance: str | None = None
|
||||
host_node: str | None = None
|
||||
deployment_ref: str | None = None
|
||||
runbook_ref: str | None = None
|
||||
upstream_oss_project: str | None = None
|
||||
|
||||
|
||||
class ServiceCatalogRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
slug: str
|
||||
name: str
|
||||
owner_or_provider: str | None = None
|
||||
category: str | None = None
|
||||
description: str | None = None
|
||||
website_url: str | None = None
|
||||
status: str
|
||||
hosting_type: str
|
||||
development_type: str
|
||||
maturity_level: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
third_party: ServiceThirdPartyRead | None = None
|
||||
first_party: ServiceFirstPartyRead | None = None
|
||||
cloud: ServiceCloudRead | None = None
|
||||
self_hosted: ServiceSelfHostedRead | None = None
|
||||
|
||||
|
||||
# ── Write (upsert) models ────────────────────────────────────────────────────
|
||||
|
||||
class ServiceThirdPartyIn(BaseModel):
|
||||
pricing_model: str = "unknown"
|
||||
upstream_packages: list | None = None
|
||||
upstream_contacts: list | None = None
|
||||
source_url: str | None = None
|
||||
support_url: str | None = None
|
||||
license: str | None = None
|
||||
|
||||
|
||||
class ServiceFirstPartyIn(BaseModel):
|
||||
repo_id: uuid.UUID | None = None
|
||||
repo_slug: str | None = None
|
||||
owning_domain: str | None = None
|
||||
|
||||
|
||||
class ServiceCloudIn(BaseModel):
|
||||
gdpr_maturity: str = "unknown"
|
||||
gdpr_notes: str | None = None
|
||||
dpa_available: bool = False
|
||||
tos_url: str | None = None
|
||||
privacy_policy_url: str | None = None
|
||||
data_processing_regions: list | None = None
|
||||
data_retention_notes: str | None = None
|
||||
|
||||
|
||||
class ServiceSelfHostedIn(BaseModel):
|
||||
helix_instance: str | None = None
|
||||
host_node: str | None = None
|
||||
deployment_ref: str | None = None
|
||||
runbook_ref: str | None = None
|
||||
upstream_oss_project: str | None = None
|
||||
|
||||
|
||||
class ServiceUpsert(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
owner_or_provider: str | None = None
|
||||
category: str | None = None
|
||||
description: str | None = None
|
||||
website_url: str | None = None
|
||||
status: str = "active"
|
||||
hosting_type: str # self_hosted | cloud_hosted
|
||||
development_type: str # first_party | third_party
|
||||
maturity_level: int | None = None
|
||||
third_party: ServiceThirdPartyIn | None = None
|
||||
first_party: ServiceFirstPartyIn | None = None
|
||||
cloud: ServiceCloudIn | None = None
|
||||
self_hosted: ServiceSelfHostedIn | None = None
|
||||
@@ -34,7 +34,16 @@ export default {
|
||||
{ name: "Inbox", path: "/inbox" },
|
||||
{ name: "Progress", path: "/progress" },
|
||||
{ name: "Token Cost", path: "/token-cost" },
|
||||
{ name: "Services (TPSC)", path: "/tpsc" },
|
||||
{
|
||||
name: "Services",
|
||||
collapsible: true,
|
||||
open: false,
|
||||
pages: [
|
||||
{ name: "Third Party", path: "/tpsc" },
|
||||
{ name: "First Party", path: "/services/first-party" },
|
||||
{ name: "Self Hosted", path: "/services/self-hosted" },
|
||||
],
|
||||
},
|
||||
{ name: "Todo", path: "/todo" },
|
||||
{ name: "Tools & Apps", path: "/tools" },
|
||||
// ── Sections (alphabetical) ───────────────────────────────────────────────
|
||||
@@ -104,6 +113,7 @@ export default {
|
||||
{ name: "Repos", path: "/docs/repos" },
|
||||
{ name: "SBOM", path: "/docs/sbom" },
|
||||
{ name: "SCOPE.md", path: "/docs/scope" },
|
||||
{ name: "Service Catalog", path: "/docs/services" },
|
||||
{ name: "Tasks", path: "/docs/tasks" },
|
||||
{ name: "TPSC", path: "/docs/tpsc" },
|
||||
{ name: "TPSC — GDPR Maturity", path: "/docs/gdpr-maturity" },
|
||||
|
||||
54
dashboard/src/docs/services.md
Normal file
54
dashboard/src/docs/services.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Service Catalog — Reference
|
||||
---
|
||||
|
||||
# Service Catalog (two dimensions)
|
||||
|
||||
Every service coulomb consumes or operates is classified along **two independent
|
||||
dimensions**, so four classes fall out of their product:
|
||||
|
||||
| | **third-party** (not dev-responsible) | **first-party** (dev-responsible) |
|
||||
|---|---|---|
|
||||
| **cloud-hosted** (consumed) | SaaS / APIs — the classic [TPSC](/docs/tpsc) | a coulomb service deployed to a cloud |
|
||||
| **self-hosted** (operated) | OSS coulomb runs (Gitea, Postgres…) | a coulomb service on coulomb infra |
|
||||
|
||||
- **Hosting** — `self_hosted` (coulomb operates the service) vs `cloud_hosted`
|
||||
(coulomb consumes someone else's running service).
|
||||
- **Development** — `first_party` (coulomb is development-responsible) vs
|
||||
`third_party` (coulomb is not).
|
||||
|
||||
## Persistence
|
||||
|
||||
A common **`service_catalog`** core table holds the shared fields
|
||||
(`slug`, `name`, `owner_or_provider`, `category`, `status`, `hosting_type`,
|
||||
`development_type`, `maturity_level`). Dimension-specific data lives in 1:1
|
||||
extension tables that **compose** — a self-hosted first-party service carries
|
||||
both the self-hosted *and* first-party extensions:
|
||||
|
||||
| Extension | Keyed on | Holds |
|
||||
|---|---|---|
|
||||
| `service_third_party` | `development_type = third_party` | upstream packages, support/service contacts, source, license, pricing |
|
||||
| `service_first_party` | `development_type = first_party` | internal dev repo (`managed_repos` FK), owning domain |
|
||||
| `service_cloud` | `hosting_type = cloud_hosted` | GDPR maturity, DPA, ToS/privacy, data-processing regions, retention |
|
||||
| `service_self_hosted` | `hosting_type = self_hosted` | three-helix instance/host, deployment & runbook refs, upstream OSS project |
|
||||
|
||||
`maturity_level` (1 · Core → 2 · Standard → 3 · Mature) tracks a service against
|
||||
the [Service DoM](/policy/service-dom).
|
||||
|
||||
## API
|
||||
|
||||
- `GET /services/catalog?hosting_type=&development_type=&maturity_level=&status=`
|
||||
— filtered list; each row includes its applicable extensions.
|
||||
- `GET /services/{slug}` — one service with extensions.
|
||||
- `POST /services/catalog` — upsert by slug; pass `first_party.repo_slug` to link
|
||||
the internal dev repo.
|
||||
|
||||
The dashboard **Services** section renders three views over this catalog:
|
||||
[Third Party](/tpsc), [First Party](/services/first-party), and
|
||||
[Self Hosted](/services/self-hosted).
|
||||
|
||||
## Migration & back-compat
|
||||
|
||||
Existing TPSC catalog rows migrated into `service_catalog` as
|
||||
`(cloud_hosted, third_party)`, reusing their ids so `tpsc_entries.catalog_id`
|
||||
keep resolving. The `/tpsc/*` endpoints and `tpsc.yaml` ingestion are unchanged.
|
||||
@@ -7,6 +7,11 @@ title: Third-Party Services Catalog (TPSC)
|
||||
The TPSC tracks external service dependencies (APIs, SaaS, CLIs) across all
|
||||
registered repos — complementing the SBOM for package dependencies.
|
||||
|
||||
> **Now part of the broader service catalog.** TPSC is the `cloud_hosted` +
|
||||
> `third_party` quadrant of the two-dimension [service catalog](/docs/services).
|
||||
> Catalog rows have migrated into `service_catalog`; the `/tpsc/*` endpoints and
|
||||
> per-repo `tpsc.yaml` dependency snapshots continue to work unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Why TPSC?
|
||||
|
||||
49
dashboard/src/services/first-party.md
Normal file
49
dashboard/src/services/first-party.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: First Party Services
|
||||
---
|
||||
|
||||
# First Party Services Catalog
|
||||
|
||||
Services **coulomb is development-responsible for** (`development_type = first_party`),
|
||||
whether deployed to a cloud or self-hosted on coulomb infrastructure. The
|
||||
**Service Maturity Level** column tracks each service against the
|
||||
[Service DoM](/policy/service-dom) (1 · Core → 2 · Standard → 3 · Mature).
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const services = await fetch(`${API}/services/catalog?development_type=first_party`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.catch(() => []);
|
||||
const repos = await fetch(`${API}/repos/`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.catch(() => []);
|
||||
const repoById = new Map(repos.map(r => [r.id, r.slug]));
|
||||
```
|
||||
|
||||
```js
|
||||
const LEVEL = {1: "1 · Core", 2: "2 · Standard", 3: "3 · Mature"};
|
||||
|
||||
const rows = services.map(s => ({
|
||||
Service: s.name,
|
||||
Slug: s.slug,
|
||||
Hosting: s.hosting_type === "self_hosted" ? "self-hosted" : "cloud-hosted",
|
||||
"Maturity Level": s.maturity_level ? LEVEL[s.maturity_level] : "—",
|
||||
"Dev Repo": s.first_party?.repo_id ? (repoById.get(s.first_party.repo_id) ?? "(unlinked)") : "—",
|
||||
Domain: s.first_party?.owning_domain ?? "—",
|
||||
Status: s.status,
|
||||
}));
|
||||
```
|
||||
|
||||
```js
|
||||
display(services.length === 0
|
||||
? html`<div style="color:#64748b;padding:1rem;">No first-party services registered yet. Add one with
|
||||
<code>POST /services/catalog</code> (<code>development_type: "first_party"</code>).</div>`
|
||||
: Inputs.table(rows, {
|
||||
columns: ["Service", "Hosting", "Maturity Level", "Dev Repo", "Domain", "Status"],
|
||||
sort: "Service",
|
||||
rows: 30,
|
||||
}));
|
||||
```
|
||||
48
dashboard/src/services/self-hosted.md
Normal file
48
dashboard/src/services/self-hosted.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
title: Self Hosted Services
|
||||
---
|
||||
|
||||
# Self Hosted Services Catalog
|
||||
|
||||
Services and webapps built on **third-party / open-source software** that coulomb
|
||||
**hosts and operates** as part of the three-helix infrastructure
|
||||
(`hosting_type = self_hosted`, `development_type = third_party`). coulomb runs
|
||||
these but is not development-responsible for them.
|
||||
|
||||
> First-party services that coulomb also self-hosts (e.g. the State Hub itself)
|
||||
> are listed under [First Party](/services/first-party), classified by who develops
|
||||
> them.
|
||||
|
||||
```js
|
||||
import {API} from "../components/config.js";
|
||||
```
|
||||
|
||||
```js
|
||||
const services = await fetch(`${API}/services/catalog?hosting_type=self_hosted&development_type=third_party`)
|
||||
.then(r => r.ok ? r.json() : [])
|
||||
.catch(() => []);
|
||||
```
|
||||
|
||||
```js
|
||||
const rows = services.map(s => ({
|
||||
Service: s.name,
|
||||
Slug: s.slug,
|
||||
"Upstream OSS": s.self_hosted?.upstream_oss_project ?? s.owner_or_provider ?? "—",
|
||||
"Helix Instance": s.self_hosted?.helix_instance ?? "—",
|
||||
Host: s.self_hosted?.host_node ?? "—",
|
||||
Runbook: s.self_hosted?.runbook_ref ?? "—",
|
||||
Status: s.status,
|
||||
}));
|
||||
```
|
||||
|
||||
```js
|
||||
display(services.length === 0
|
||||
? html`<div style="color:#64748b;padding:1rem;">No self-hosted third-party services registered yet. Add one with
|
||||
<code>POST /services/catalog</code> (<code>hosting_type: "self_hosted"</code>,
|
||||
<code>development_type: "third_party"</code>).</div>`
|
||||
: Inputs.table(rows, {
|
||||
columns: ["Service", "Upstream OSS", "Helix Instance", "Host", "Runbook", "Status"],
|
||||
sort: "Service",
|
||||
rows: 30,
|
||||
}));
|
||||
```
|
||||
131
migrations/versions/c7d8e9f0a1b2_service_catalog.py
Normal file
131
migrations/versions/c7d8e9f0a1b2_service_catalog.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""two-dimension service catalog (STATE-WP-0062)
|
||||
|
||||
Creates service_catalog + per-dimension extension tables and migrates existing
|
||||
tpsc_catalog rows into it as (cloud_hosted, third_party). The service_catalog id
|
||||
reuses the tpsc_catalog id so existing tpsc_entries.catalog_id values continue to
|
||||
resolve against the new core table without a column change.
|
||||
|
||||
Revision ID: c7d8e9f0a1b2
|
||||
Revises: 5733434addf4
|
||||
Create Date: 2026-06-19
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
|
||||
revision = "c7d8e9f0a1b2"
|
||||
down_revision = "5733434addf4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"service_catalog",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=200), nullable=False),
|
||||
sa.Column("owner_or_provider", sa.String(length=200), nullable=True),
|
||||
sa.Column("category", sa.String(length=100), nullable=True),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("website_url", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), server_default="active", nullable=False),
|
||||
sa.Column("hosting_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("development_type", sa.String(length=20), nullable=False),
|
||||
sa.Column("maturity_level", sa.Integer(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||
sa.UniqueConstraint("slug", name="uq_service_catalog_slug"),
|
||||
)
|
||||
op.create_index("ix_service_catalog_slug", "service_catalog", ["slug"])
|
||||
op.create_index("ix_service_catalog_hosting_type", "service_catalog", ["hosting_type"])
|
||||
op.create_index("ix_service_catalog_development_type", "service_catalog", ["development_type"])
|
||||
|
||||
op.create_table(
|
||||
"service_third_party",
|
||||
sa.Column("service_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("pricing_model", sa.String(length=20), server_default="unknown", nullable=False),
|
||||
sa.Column("upstream_packages", JSON(), nullable=True),
|
||||
sa.Column("upstream_contacts", JSON(), nullable=True),
|
||||
sa.Column("source_url", sa.Text(), nullable=True),
|
||||
sa.Column("support_url", sa.Text(), nullable=True),
|
||||
sa.Column("license", sa.String(length=100), nullable=True),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"service_first_party",
|
||||
sa.Column("service_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("repo_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("owning_domain", sa.String(length=100), nullable=True),
|
||||
)
|
||||
op.create_index("ix_service_first_party_repo_id", "service_first_party", ["repo_id"])
|
||||
|
||||
op.create_table(
|
||||
"service_cloud",
|
||||
sa.Column("service_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("gdpr_maturity", sa.String(length=20), server_default="unknown", nullable=False),
|
||||
sa.Column("gdpr_notes", sa.Text(), nullable=True),
|
||||
sa.Column("dpa_available", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("tos_url", sa.Text(), nullable=True),
|
||||
sa.Column("privacy_policy_url", sa.Text(), nullable=True),
|
||||
sa.Column("data_processing_regions", JSON(), nullable=True),
|
||||
sa.Column("data_retention_notes", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_service_cloud_gdpr_maturity", "service_cloud", ["gdpr_maturity"])
|
||||
|
||||
op.create_table(
|
||||
"service_self_hosted",
|
||||
sa.Column("service_id", UUID(as_uuid=True),
|
||||
sa.ForeignKey("service_catalog.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("helix_instance", sa.String(length=100), nullable=True),
|
||||
sa.Column("host_node", sa.String(length=100), nullable=True),
|
||||
sa.Column("deployment_ref", sa.Text(), nullable=True),
|
||||
sa.Column("runbook_ref", sa.Text(), nullable=True),
|
||||
sa.Column("upstream_oss_project", sa.String(length=200), nullable=True),
|
||||
)
|
||||
|
||||
# ── Data migration: tpsc_catalog → service_catalog (cloud_hosted, third_party)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO service_catalog
|
||||
(id, slug, name, owner_or_provider, category, website_url, status,
|
||||
hosting_type, development_type, maturity_level, created_at, updated_at)
|
||||
SELECT id, slug, name, provider, category, website_url, status,
|
||||
'cloud_hosted', 'third_party', NULL, created_at, updated_at
|
||||
FROM tpsc_catalog
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO service_third_party (service_id, pricing_model)
|
||||
SELECT id, pricing_model FROM tpsc_catalog
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
INSERT INTO service_cloud
|
||||
(service_id, gdpr_maturity, gdpr_notes, dpa_available, tos_url,
|
||||
privacy_policy_url, data_processing_regions, data_retention_notes)
|
||||
SELECT id, gdpr_maturity, gdpr_notes, dpa_available, tos_url,
|
||||
privacy_policy_url, data_processing_regions, data_retention_notes
|
||||
FROM tpsc_catalog
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_service_cloud_gdpr_maturity", table_name="service_cloud")
|
||||
op.drop_table("service_self_hosted")
|
||||
op.drop_table("service_cloud")
|
||||
op.drop_index("ix_service_first_party_repo_id", table_name="service_first_party")
|
||||
op.drop_table("service_first_party")
|
||||
op.drop_table("service_third_party")
|
||||
op.drop_index("ix_service_catalog_development_type", table_name="service_catalog")
|
||||
op.drop_index("ix_service_catalog_hosting_type", table_name="service_catalog")
|
||||
op.drop_index("ix_service_catalog_slug", table_name="service_catalog")
|
||||
op.drop_table("service_catalog")
|
||||
@@ -6,13 +6,13 @@ criteria below are satisfied. This is the service-level companion to the
|
||||
Repository *Definition of Integrated* (`repo-doi.md`) and the Workstream
|
||||
*Definition of Done* (`workstream-dod.md`).
|
||||
|
||||
Criteria are grouped by tier: a service that meets all **Core** criteria is
|
||||
*operable*; meeting **Standard** criteria makes it *observable*; meeting
|
||||
**Full** criteria makes it *mature*.
|
||||
Criteria are grouped by **Service Maturity Level**: a service that meets all
|
||||
**Core** criteria is *operable*; meeting **Standard** criteria makes it
|
||||
*observable*; meeting **Full** criteria makes it *mature*.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — Core (Operable)
|
||||
## Level 1 — Core (Operable)
|
||||
|
||||
The minimum for a service to be run and reasoned about by agents and operators.
|
||||
|
||||
@@ -35,7 +35,7 @@ The minimum for a service to be run and reasoned about by agents and operators.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Standard (Observable)
|
||||
## Level 2 — Standard (Observable)
|
||||
|
||||
The service can be monitored and integrated by other agents and tooling.
|
||||
|
||||
@@ -57,7 +57,7 @@ The service can be monitored and integrated by other agents and tooling.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Full (Mature)
|
||||
## Level 3 — Full (Mature)
|
||||
|
||||
The service participates safely in the wider ecosystem over time.
|
||||
|
||||
@@ -79,19 +79,19 @@ The service participates safely in the wider ecosystem over time.
|
||||
|
||||
## Maturity Checklist (Quick Reference)
|
||||
|
||||
| # | Criterion | Tier | Verified by |
|
||||
| # | Criterion | Level | Verified by |
|
||||
|---|---|---|---|
|
||||
| 1 | Health endpoint | Core | `curl -s $BASE/state/health` → `200`, `{"status":"ok"}` |
|
||||
| 2 | Start command documented | Core | `make api` from clean checkout |
|
||||
| 3 | Bound address known | Core | docs / `CLAUDE.md` |
|
||||
| 4 | Health route is tested | Standard | `tests/` asserts health route |
|
||||
| 5 | Dependencies declared | Standard | `make ingest-tpsc` |
|
||||
| 6 | Remote reachability path | Standard | ops-bridge health probe |
|
||||
| 7 | Graceful dependency failure | Standard | health returns `503` when DB down |
|
||||
| 8 | Versioned interface | Full | `publish_interface_change` |
|
||||
| 9 | Authn/authz boundary documented | Full | docs review |
|
||||
| 10 | Recovery documented | Full | runbook present |
|
||||
| 11 | Lifecycle telemetry | Full | `add_progress_event` on lifecycle |
|
||||
| 1 | Health endpoint | 1 · Core | `curl -s $BASE/state/health` → `200`, `{"status":"ok"}` |
|
||||
| 2 | Start command documented | 1 · Core | `make api` from clean checkout |
|
||||
| 3 | Bound address known | 1 · Core | docs / `CLAUDE.md` |
|
||||
| 4 | Health route is tested | 2 · Standard | `tests/` asserts health route |
|
||||
| 5 | Dependencies declared | 2 · Standard | `make ingest-tpsc` |
|
||||
| 6 | Remote reachability path | 2 · Standard | ops-bridge health probe |
|
||||
| 7 | Graceful dependency failure | 2 · Standard | health returns `503` when DB down |
|
||||
| 8 | Versioned interface | 3 · Full | `publish_interface_change` |
|
||||
| 9 | Authn/authz boundary documented | 3 · Full | docs review |
|
||||
| 10 | Recovery documented | 3 · Full | runbook present |
|
||||
| 11 | Lifecycle telemetry | 3 · Full | `add_progress_event` on lifecycle |
|
||||
|
||||
---
|
||||
|
||||
|
||||
108
tests/test_services_router.py
Normal file
108
tests/test_services_router.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests for the two-dimension service catalog API (STATE-WP-0062)."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def _svc(slug, hosting, development, **extra):
|
||||
body = {
|
||||
"slug": slug,
|
||||
"name": slug.replace("-", " ").title(),
|
||||
"hosting_type": hosting,
|
||||
"development_type": development,
|
||||
}
|
||||
body.update(extra)
|
||||
return body
|
||||
|
||||
|
||||
async def test_upsert_cloud_third_party_with_extensions(client):
|
||||
r = await client.post("/services/catalog", json=_svc(
|
||||
"openai-api", "cloud_hosted", "third_party",
|
||||
owner_or_provider="OpenAI",
|
||||
cloud={"gdpr_maturity": "developing", "dpa_available": True},
|
||||
third_party={"pricing_model": "usage_based", "support_url": "https://help.openai.com"},
|
||||
))
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["hosting_type"] == "cloud_hosted"
|
||||
assert body["development_type"] == "third_party"
|
||||
assert body["cloud"]["gdpr_maturity"] == "developing"
|
||||
assert body["cloud"]["dpa_available"] is True
|
||||
assert body["third_party"]["pricing_model"] == "usage_based"
|
||||
assert body["first_party"] is None
|
||||
assert body["self_hosted"] is None
|
||||
|
||||
|
||||
async def test_upsert_self_hosted_first_party_with_maturity(client):
|
||||
r = await client.post("/services/catalog", json=_svc(
|
||||
"state-hub", "self_hosted", "first_party",
|
||||
maturity_level=2,
|
||||
first_party={"owning_domain": "custodian"},
|
||||
self_hosted={"helix_instance": "coulombcore", "runbook_ref": "make api"},
|
||||
))
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["maturity_level"] == 2
|
||||
assert body["first_party"]["owning_domain"] == "custodian"
|
||||
assert body["self_hosted"]["helix_instance"] == "coulombcore"
|
||||
assert body["cloud"] is None
|
||||
|
||||
|
||||
async def test_dimension_filters(client):
|
||||
await client.post("/services/catalog", json=_svc("gitea", "self_hosted", "third_party",
|
||||
self_hosted={"upstream_oss_project": "Gitea"}))
|
||||
await client.post("/services/catalog", json=_svc("stripe", "cloud_hosted", "third_party"))
|
||||
await client.post("/services/catalog", json=_svc("hub", "self_hosted", "first_party", maturity_level=3))
|
||||
|
||||
self_hosted = (await client.get("/services/catalog?hosting_type=self_hosted")).json()
|
||||
assert {s["slug"] for s in self_hosted} == {"gitea", "hub"}
|
||||
|
||||
first_party = (await client.get("/services/catalog?development_type=first_party")).json()
|
||||
assert {s["slug"] for s in first_party} == {"hub"}
|
||||
|
||||
level3 = (await client.get("/services/catalog?maturity_level=3")).json()
|
||||
assert {s["slug"] for s in level3} == {"hub"}
|
||||
|
||||
|
||||
async def test_upsert_is_idempotent_on_slug(client):
|
||||
await client.post("/services/catalog", json=_svc("svc-x", "cloud_hosted", "third_party",
|
||||
category="search"))
|
||||
r = await client.post("/services/catalog", json=_svc("svc-x", "cloud_hosted", "third_party",
|
||||
category="storage"))
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["category"] == "storage"
|
||||
listed = (await client.get("/services/catalog")).json()
|
||||
assert sum(1 for s in listed if s["slug"] == "svc-x") == 1
|
||||
|
||||
|
||||
async def test_invalid_dimensions_rejected(client):
|
||||
r = await client.post("/services/catalog", json=_svc("bad", "on_prem", "third_party"))
|
||||
assert r.status_code == 422
|
||||
r = await client.post("/services/catalog", json=_svc("bad2", "cloud_hosted", "nobody"))
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
async def test_first_party_unknown_repo_slug_404(client):
|
||||
r = await client.post("/services/catalog", json=_svc(
|
||||
"svc-y", "self_hosted", "first_party",
|
||||
first_party={"repo_slug": "does-not-exist"},
|
||||
))
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_get_unknown_service_404(client):
|
||||
r = await client.get("/services/nope")
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
async def test_first_party_repo_slug_links_to_repo(client):
|
||||
await client.post("/domains/", json={"slug": "custodian", "name": "Custodian"})
|
||||
repo = (await client.post("/repos/", json={
|
||||
"domain_slug": "custodian", "slug": "state-hub", "name": "State Hub",
|
||||
})).json()
|
||||
|
||||
r = await client.post("/services/catalog", json=_svc(
|
||||
"state-hub-api", "self_hosted", "first_party",
|
||||
maturity_level=2,
|
||||
first_party={"repo_slug": "state-hub", "owning_domain": "custodian"},
|
||||
))
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["first_party"]["repo_id"] == repo["id"]
|
||||
@@ -4,11 +4,12 @@ type: workplan
|
||||
title: "Two-dimension service catalog (hosting × development) + Services nav section"
|
||||
domain: custodian
|
||||
repo: state-hub
|
||||
status: proposed
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-19"
|
||||
updated: "2026-06-19"
|
||||
state_hub_workstream_id: "b5c9d93f-9f5e-4d10-bb3b-90322c7419b7"
|
||||
---
|
||||
|
||||
# STATE-WP-0062 — Two-dimension service catalog + Services nav section
|
||||
@@ -92,55 +93,59 @@ tables (each 1:1, optional, keyed by `service_id`):
|
||||
|
||||
```task
|
||||
id: STATE-WP-0062-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f74612f6-b442-4b5f-ac4d-7bc6d1d4883f"
|
||||
```
|
||||
|
||||
- [ ] `api/models/service_catalog.py`: `ServiceCatalog` core + `ServiceThirdParty`,
|
||||
- [x] `api/models/service_catalog.py`: `ServiceCatalog` core + `ServiceThirdParty`,
|
||||
`ServiceFirstParty`, `ServiceCloud`, `ServiceSelfHosted` extensions.
|
||||
- [ ] Alembic migration; register in `api/models/__init__.py`.
|
||||
- [ ] Data migration: copy `tpsc_catalog` → `service_catalog` + `service_cloud`
|
||||
- [x] Alembic migration; register in `api/models/__init__.py`.
|
||||
- [x] Data migration: copy `tpsc_catalog` → `service_catalog` + `service_cloud`
|
||||
with `(cloud_hosted, third_party)`; backfill `service_id` on TPSC entries.
|
||||
|
||||
### T2 — API + MCP
|
||||
|
||||
```task
|
||||
id: STATE-WP-0062-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "373cddfa-c85b-47a7-bb8e-c86e9341c237"
|
||||
```
|
||||
|
||||
- [ ] `GET /services/catalog` with `hosting_type` / `development_type` /
|
||||
- [x] `GET /services/catalog` with `hosting_type` / `development_type` /
|
||||
`maturity_level` filters; `GET /services/{slug}` returns core + applicable
|
||||
extensions.
|
||||
- [ ] Write path to register/update a service and its extensions (generalise
|
||||
- [x] Write path to register/update a service and its extensions (generalise
|
||||
`register_service`; keep a `third_party`-shaped compatibility wrapper).
|
||||
- [ ] Keep `/tpsc/*` working as a `development_type=third_party` view.
|
||||
- [x] Keep `/tpsc/*` working as a `development_type=third_party` view.
|
||||
|
||||
### T3 — Services nav section + four-quadrant pages
|
||||
|
||||
```task
|
||||
id: STATE-WP-0062-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "b14bbbdd-347e-4f62-9ac3-ad42360fa766"
|
||||
```
|
||||
|
||||
- [ ] Replace the top-level "Services (TPSC)" entry with a **Services** section.
|
||||
- [ ] Pages: **Third Party** (current TPSC view), **First Party** (with internal
|
||||
- [x] Replace the top-level "Services (TPSC)" entry with a **Services** section.
|
||||
- [x] Pages: **Third Party** (current TPSC view), **First Party** (with internal
|
||||
repo link + **Service Maturity Level** column), **Self Hosted** (three-helix
|
||||
infra view). Each is a filtered view over `service_catalog`.
|
||||
- [ ] Surface the two dimensions explicitly (e.g. a quadrant filter) so the four
|
||||
- [x] Surface the two dimensions explicitly (e.g. a quadrant filter) so the four
|
||||
classes are navigable.
|
||||
|
||||
### T4 — Terminology: Tier → Level (Service DoM only)
|
||||
|
||||
```task
|
||||
id: STATE-WP-0062-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "1c296d7a-c090-430f-8d0d-27b548d88d9e"
|
||||
```
|
||||
|
||||
- [ ] `policies/service-dom.md`: rename "Tier 1/2/3" → "Level 1/2/3"; column
|
||||
- [x] `policies/service-dom.md`: rename "Tier 1/2/3" → "Level 1/2/3"; column
|
||||
header "Service Maturity Level". **Do not** touch the DoI tier subsystem
|
||||
(`repos.md`, `check_doi.py`, `doi_cache` migration) — that is a separate,
|
||||
established concept.
|
||||
@@ -149,15 +154,16 @@ priority: medium
|
||||
|
||||
```task
|
||||
id: STATE-WP-0062-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "2edd68ee-0d9a-431d-9891-73a0f6be6a41"
|
||||
```
|
||||
|
||||
- [ ] Tests: model + migration round-trip, dimension filters, TPSC back-compat
|
||||
- [x] Tests: model + migration round-trip, dimension filters, TPSC back-compat
|
||||
view, first-party↔repo link.
|
||||
- [ ] Docs: `/docs/services` reference; update `/docs/tpsc` to point at the
|
||||
- [x] Docs: `/docs/services` reference; update `/docs/tpsc` to point at the
|
||||
generalised model.
|
||||
- [ ] Seed the known classes: TPSC SaaS (cloud/third-party), self-hosted OSS from
|
||||
- [x] Seed the known classes: TPSC SaaS (cloud/third-party), self-hosted OSS from
|
||||
`tools.md` (Gitea, Postgres — self-hosted/third-party), and State Hub itself
|
||||
(self-hosted/first-party).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user