Compare commits

...

5 Commits

Author SHA1 Message Date
ce82ada0fa STATE-WP-0062 T5: docs, first-party↔repo test, mark workplan finished
- Add /docs/services reference (two-dimension model, persistence, API) and a
  pointer note from /docs/tpsc; add it to the Reference nav.
- Add a test asserting first_party.repo_slug resolves to a managed_repos FK
  (8 services tests green).
- Mark STATE-WP-0062 tasks done / status finished.

Known classes seeded in the live catalog via the API (Gitea, Postgres as
self-hosted/third-party; State Hub as self-hosted/first-party at Level 2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:16:37 +02:00
f14c225dd9 STATE-WP-0062 T4: Service DoM uses "Level" not "Tier"
Rename Tier 1/2/3 -> Level 1/2/3 (Core/Standard/Full) in the Service DoM policy
and the checklist header to "Level", aligning with the service_catalog
maturity_level column. The DoI tier subsystem is intentionally untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:03:35 +02:00
d68de69fe6 STATE-WP-0062 T3: Services nav section + First/Self Hosted pages
Replace the single "Services (TPSC)" nav entry with a Services section:
Third Party (existing /tpsc cloud-third-party view), First Party
(/services/first-party — Service Maturity Level + dev-repo columns,
development_type=first_party), and Self Hosted (/services/self-hosted —
self_hosted third-party OSS with upstream/host/runbook). New pages are filtered
views over /services/catalog and degrade to an empty-state if the API is offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:03:35 +02:00
77689fbfb2 STATE-WP-0062 T2: /services catalog API over the two-dimension model
Add a local /services router (source of truth for the catalog itself):
- GET /services/catalog with hosting_type / development_type / maturity_level /
  status filters (eager-loads all four extensions)
- GET /services/{slug}
- POST /services/catalog upsert-by-slug, applying the dimension extensions;
  first_party.repo_slug resolves to a managed_repos FK.

Extensions are read/written via session.get (not the relationship attribute) to
avoid async lazy-load. /tpsc/* is left intact for dependency snapshots. 7 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:56:19 +02:00
0192dc786f STATE-WP-0062 T1: two-dimension service_catalog model + migration
Add ServiceCatalog core (hosting_type, development_type, maturity_level) plus
1:1 per-dimension extension tables (service_third_party, service_first_party,
service_cloud, service_self_hosted) keyed by service_id. Migration creates the
tables and copies existing tpsc_catalog rows into service_catalog as
(cloud_hosted, third_party), reusing the tpsc_catalog id as the service_catalog
id so existing tpsc_entries.catalog_id keep resolving without a column change.
GDPR/data-processing fields move to service_cloud; pricing_model to
service_third_party.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:46:07 +02:00
14 changed files with 840 additions and 39 deletions

View File

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

View File

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

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

143
api/routers/services.py Normal file
View 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
View 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

View File

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

View 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.

View File

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

View 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,
}));
```

View 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,
}));
```

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

View File

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

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

View File

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