diff --git a/dashboard/observablehq.config.js b/dashboard/observablehq.config.js index f10d757..d885655 100644 --- a/dashboard/observablehq.config.js +++ b/dashboard/observablehq.config.js @@ -113,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" }, diff --git a/dashboard/src/docs/services.md b/dashboard/src/docs/services.md new file mode 100644 index 0000000..854fefd --- /dev/null +++ b/dashboard/src/docs/services.md @@ -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. diff --git a/dashboard/src/docs/tpsc.md b/dashboard/src/docs/tpsc.md index 08d4f7d..b376792 100644 --- a/dashboard/src/docs/tpsc.md +++ b/dashboard/src/docs/tpsc.md @@ -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? diff --git a/tests/test_services_router.py b/tests/test_services_router.py index 2c68069..8dc03d9 100644 --- a/tests/test_services_router.py +++ b/tests/test_services_router.py @@ -91,3 +91,18 @@ async def test_first_party_unknown_repo_slug_404(client): 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"] diff --git a/workplans/STATE-WP-0062-service-catalog-two-dimensions.md b/workplans/STATE-WP-0062-service-catalog-two-dimensions.md index 1e67b8a..745a895 100644 --- a/workplans/STATE-WP-0062-service-catalog-two-dimensions.md +++ b/workplans/STATE-WP-0062-service-catalog-two-dimensions.md @@ -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).