From b767043b3f4dd0d4cea515b647e19759ab233806 Mon Sep 17 00:00:00 2001 From: Shaun Smith <51304449+smith-xyz@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:21:45 -0400 Subject: [PATCH] feat(core): land platform API symbols as typed stubs (XWING-2206) --- .agents/skills/python-patterns/SKILL.md | 60 ++++ .../python-patterns/references/idioms.py | 65 ++++ .agents/skills/traust-core-patterns/SKILL.md | 11 +- .../skills/traust-core-patterns/provider.md | 2 +- .../skills/traust-core-patterns/repository.md | 20 +- .../skills/traust-core-patterns/service.md | 30 +- .../skills/traust-core-patterns/versioning.md | 13 - .githooks/commit-msg | 9 + .githooks/pre-commit | 9 + .githooks/pre-push | 7 + .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 41 --- AGENTS.md | 63 +++- Makefile | 29 +- README.md | 19 +- VERSION | 1 - pyproject.toml | 6 +- release.py | 328 ------------------ src/traust_core/v1/context/config.py | 6 + src/traust_core/v1/context/context.py | 8 + src/traust_core/v1/models/artifacts.py | 54 ++- src/traust_core/v1/models/assessment.py | 108 +++++- src/traust_core/v1/models/attestation.py | 72 +++- src/traust_core/v1/models/base.py | 10 +- src/traust_core/v1/models/code_facts.py | 77 +++- src/traust_core/v1/models/common.py | 86 +++++ src/traust_core/v1/models/compliance.py | 128 ++++++- .../v1/models/detection_quality.py | 98 +++++- src/traust_core/v1/models/exposure.py | 124 ++++++- src/traust_core/v1/models/findings.py | 190 +++++++++- src/traust_core/v1/models/insight.py | 40 +++ src/traust_core/v1/models/ledger.py | 48 +++ src/traust_core/v1/models/onboarding.py | 47 +++ src/traust_core/v1/models/operations.py | 96 ++++- src/traust_core/v1/models/portfolio.py | 134 ++++++- src/traust_core/v1/models/pqc.py | 44 +++ src/traust_core/v1/models/remediation.py | 111 +++++- src/traust_core/v1/models/validation.py | 104 +++++- src/traust_core/v1/models/values.py | 2 + src/traust_core/v1/models/vocabulary.py | 135 +++++++ src/traust_core/v1/providers/analysis.py | 25 ++ src/traust_core/v1/providers/base.py | 4 +- src/traust_core/v1/providers/collaboration.py | 41 +++ src/traust_core/v1/providers/feeds.py | 9 + src/traust_core/v1/providers/identity.py | 24 ++ src/traust_core/v1/providers/ledger.py | 23 ++ src/traust_core/v1/providers/model_catalog.py | 22 ++ src/traust_core/v1/providers/runtime.py | 26 ++ src/traust_core/v1/providers/sources.py | 11 + src/traust_core/v1/providers/tools.py | 9 + src/traust_core/v1/providers/validation.py | 18 + src/traust_core/v1/rendering/__init__.py | 2 + src/traust_core/v1/rendering/html.py | 10 + src/traust_core/v1/repositories/artifacts.py | 32 ++ src/traust_core/v1/repositories/assessment.py | 37 +- .../v1/repositories/attestation.py | 17 +- src/traust_core/v1/repositories/code_facts.py | 14 +- src/traust_core/v1/repositories/compliance.py | 42 ++- .../v1/repositories/detection_quality.py | 36 +- src/traust_core/v1/repositories/exposure.py | 47 ++- src/traust_core/v1/repositories/findings.py | 45 ++- src/traust_core/v1/repositories/migrations.py | 20 ++ .../v1/repositories/object_store.py | 2 + src/traust_core/v1/repositories/operations.py | 40 ++- src/traust_core/v1/repositories/portfolio.py | 63 +++- .../v1/repositories/remediation.py | 32 +- src/traust_core/v1/repositories/sql.py | 9 +- src/traust_core/v1/repositories/triage.py | 2 +- src/traust_core/v1/repositories/validation.py | 18 +- src/traust_core/v1/services/__init__.py | 4 +- .../v1/services/artifact_publishing.py | 27 +- src/traust_core/v1/services/assessment.py | 73 ++-- src/traust_core/v1/services/attestation.py | 75 +++- src/traust_core/v1/services/code_facts.py | 40 ++- src/traust_core/v1/services/compliance.py | 78 ++++- .../v1/services/detection_quality.py | 64 +++- src/traust_core/v1/services/exposure.py | 79 ++++- .../v1/services/findings/__init__.py | 7 +- .../v1/services/findings/decisions.py | 39 ++- .../v1/services/findings/identity.py | 14 +- .../v1/services/findings/intake.py | 21 +- .../v1/services/findings/precedents.py | 11 +- .../v1/services/findings/rating.py | 25 +- .../v1/services/findings/review.py | 27 +- .../v1/services/findings/service.py | 59 +++- .../v1/services/findings/taxonomy.py | 12 + .../v1/services/findings/transitions.py | 16 - src/traust_core/v1/services/insight.py | 107 ++++++ src/traust_core/v1/services/ledger.py | 30 ++ src/traust_core/v1/services/lint.py | 38 ++ src/traust_core/v1/services/onboarding.py | 27 ++ src/traust_core/v1/services/operations.py | 81 ++++- src/traust_core/v1/services/portfolio.py | 126 +++++-- src/traust_core/v1/services/pqc.py | 47 +++ .../v1/services/provider_catalog.py | 30 ++ src/traust_core/v1/services/remediation.py | 102 +++++- src/traust_core/v1/services/validation.py | 73 +++- tests/v1/conftest.py | 6 + tests/v1/example/provider.py | 4 +- tests/v1/models/test_artifacts.py | 9 +- tests/v1/models/test_base.py | 6 - tests/v1/providers/test_base.py | 2 +- tests/v1/services/findings/test_decisions.py | 11 - tests/v1/services/findings/test_identity.py | 9 - tests/v1/services/findings/test_intake.py | 11 - tests/v1/services/findings/test_precedents.py | 11 - tests/v1/services/findings/test_rating.py | 9 - tests/v1/services/findings/test_review.py | 11 - tests/v1/services/findings/test_service.py | 23 -- .../v1/services/findings/test_transitions.py | 9 - tests/v1/services/test_assessment.py | 11 - tests/v1/services/test_attestation.py | 11 - tests/v1/services/test_code_facts.py | 11 - tests/v1/services/test_compliance.py | 11 - tests/v1/services/test_detection_quality.py | 11 - tests/v1/services/test_exposure.py | 11 - tests/v1/services/test_operations.py | 11 - tests/v1/services/test_portfolio.py | 14 - tests/v1/services/test_remediation.py | 11 - tests/v1/services/test_validation.py | 11 - tests/v1/stubs.py | 18 - tests/v1/test_example_continuous_ops.py | 4 +- tests/v1/test_stubs.py | 91 +++++ 123 files changed, 3783 insertions(+), 1013 deletions(-) create mode 100644 .agents/skills/python-patterns/SKILL.md create mode 100644 .agents/skills/python-patterns/references/idioms.py delete mode 100644 .agents/skills/traust-core-patterns/versioning.md create mode 100755 .githooks/commit-msg create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push delete mode 100644 .github/workflows/release.yml delete mode 100644 VERSION delete mode 100755 release.py create mode 100644 src/traust_core/v1/models/common.py create mode 100644 src/traust_core/v1/models/insight.py create mode 100644 src/traust_core/v1/models/ledger.py create mode 100644 src/traust_core/v1/models/onboarding.py create mode 100644 src/traust_core/v1/models/pqc.py create mode 100644 src/traust_core/v1/models/vocabulary.py create mode 100644 src/traust_core/v1/providers/analysis.py create mode 100644 src/traust_core/v1/providers/collaboration.py create mode 100644 src/traust_core/v1/providers/identity.py create mode 100644 src/traust_core/v1/providers/ledger.py create mode 100644 src/traust_core/v1/providers/model_catalog.py create mode 100644 src/traust_core/v1/providers/runtime.py create mode 100644 src/traust_core/v1/providers/validation.py create mode 100644 src/traust_core/v1/rendering/html.py create mode 100644 src/traust_core/v1/repositories/artifacts.py create mode 100644 src/traust_core/v1/repositories/migrations.py create mode 100644 src/traust_core/v1/services/findings/taxonomy.py delete mode 100644 src/traust_core/v1/services/findings/transitions.py create mode 100644 src/traust_core/v1/services/insight.py create mode 100644 src/traust_core/v1/services/ledger.py create mode 100644 src/traust_core/v1/services/lint.py create mode 100644 src/traust_core/v1/services/onboarding.py create mode 100644 src/traust_core/v1/services/pqc.py create mode 100644 src/traust_core/v1/services/provider_catalog.py delete mode 100644 tests/v1/models/test_base.py delete mode 100644 tests/v1/services/findings/test_decisions.py delete mode 100644 tests/v1/services/findings/test_identity.py delete mode 100644 tests/v1/services/findings/test_intake.py delete mode 100644 tests/v1/services/findings/test_precedents.py delete mode 100644 tests/v1/services/findings/test_rating.py delete mode 100644 tests/v1/services/findings/test_review.py delete mode 100644 tests/v1/services/findings/test_service.py delete mode 100644 tests/v1/services/findings/test_transitions.py delete mode 100644 tests/v1/services/test_assessment.py delete mode 100644 tests/v1/services/test_attestation.py delete mode 100644 tests/v1/services/test_code_facts.py delete mode 100644 tests/v1/services/test_compliance.py delete mode 100644 tests/v1/services/test_detection_quality.py delete mode 100644 tests/v1/services/test_exposure.py delete mode 100644 tests/v1/services/test_operations.py delete mode 100644 tests/v1/services/test_portfolio.py delete mode 100644 tests/v1/services/test_remediation.py delete mode 100644 tests/v1/services/test_validation.py delete mode 100644 tests/v1/stubs.py create mode 100644 tests/v1/test_stubs.py diff --git a/.agents/skills/python-patterns/SKILL.md b/.agents/skills/python-patterns/SKILL.md new file mode 100644 index 00000000..66144b82 --- /dev/null +++ b/.agents/skills/python-patterns/SKILL.md @@ -0,0 +1,60 @@ +--- +name: python-patterns +description: Python style for traust-core — typing, models, errors, idioms, concise code. Use whenever writing or reviewing Python in this repo, alongside traust-core-patterns. Read AGENTS.md → Design rules first. +--- + +# Python patterns (traust-core) + +Adapted from the house `python-patterns` skill. Where they differ, `AGENTS.md` wins: no generic repository/service bases, no result wrappers, no `Any` or `dict` across boundaries, sync code (SQLAlchemy Core). + +## Workflow + +1. **Clarify** — inputs/outputs, which domain owns it, which class is responsible (`AGENTS.md` → Design rules). +2. **Find the owner** — search for an existing model/service/repository method before adding one. +3. **Build** — models (with their own rules) → repository port → service method → adapter (CLI/`Materializer`) if needed. + +## Style + +- **Concise** — fewest lines that stay clear; no boilerplate, no speculative helpers +- **OOP for responsibilities** — one class per domain service, policy or repository; behaviour about one entity lives on its model +- **Functional for transforms** — comprehensions over loops when clearer +- **Types always** — every parameter and return annotated; IDs are `UUID` named `_id` +- **Docstrings 1–2 lines max, comments almost never** — names and types explain. Bloated comments, narration, banners and over-explaining are not allowed at all; the only comment is a one-line non-obvious *why* +- **Effective logging** — follow AGENTS.md → Logging and tracing: module logger (`logging.getLogger(__name__)`), log at boundaries with the correlation ID, right level, no secrets; never log-and-continue on errors + +## Patterns + +| Need | Use | +|---|---| +| Internal domain object | `Model` / `Entity` (`models/base.py`, strict, frozen) | +| Boundary data (tolerant input) | `Dto` | +| Value object | `@dataclass(frozen=True, slots=True)` with a `parse` classmethod | +| Closed set | `StrEnum`, or `models/vocabulary.py` for contracts enums | +| Identifier | `UUID`, named `_id`; inherited `id` on `Entity`, generated by the database on insert, `None` until saved — never minted in code | +| Optional / unknown | `T \| None` (unknown is `None`, never `0` or `""`) | +| Config | a section on `context/config.py` `Config`; secrets by env-var name only | +| Error with context | `raise NotFoundError(f"finding {id}") from e` (`traust_core.v1.errors`) | +| Resources | context managers (`with uow:`) | +| External process / clock | `ProcessRunner` / `Clock` — never `subprocess` or `datetime.now()` | + +## Avoid (the slop we are removing) + +- Generic `Repository[T]` / `Service[T]` bases, `save`/`delete` CRUD, singletons, factories without a second implementation +- `ServiceResult`-style success/error wrappers — raise typed errors; adapters translate to `JobResult` +- `dict[str, Any]`, `Any`, bare `str` IDs or kinds across a function boundary +- Re-implementing something another module owns (rating, identity, disposition, rendering, escaping) +- Reading files, env vars or databases from services; building paths anywhere but adapters + +## References + +- [references/idioms.py](references/idioms.py) — concise idioms in traust-core shapes + +## Checklist + +- Every signature typed? IDs typed, enums for closed sets? +- No comment that narrates or over-explains? Docstrings ≤ 2 lines? +- `Model`/`Dto`/frozen dataclass chosen correctly? +- Behaviour on the model when it is about one entity's own state? +- One owner — no second implementation of an existing capability? +- Errors from `traust_core.v1.errors`, wrapped with `from e`? +- `make lint-fix && make test` green? diff --git a/.agents/skills/python-patterns/references/idioms.py b/.agents/skills/python-patterns/references/idioms.py new file mode 100644 index 00000000..98e2c3d0 --- /dev/null +++ b/.agents/skills/python-patterns/references/idioms.py @@ -0,0 +1,65 @@ +"""Concise idioms in traust-core shapes (reference only, not imported).""" + +from __future__ import annotations + +import re +from collections import Counter +from dataclasses import dataclass +from datetime import datetime +from enum import StrEnum +from typing import Self +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class NotFoundError(LookupError): ... + + +class Model(BaseModel): + model_config = ConfigDict(frozen=True, extra="forbid") + + +class Status(StrEnum): + OPEN = "open" + DONE = "done" + + +@dataclass(frozen=True, slots=True) +class Slug: + value: str + + @classmethod + def parse(cls, raw: str) -> Self: + if not re.fullmatch(r"[a-z0-9][a-z0-9-]*", raw): + raise ValueError(f"not a slug: {raw!r}") + return cls(raw) + + +class Task(Model): + id: UUID | None = None + slug: Slug + status: Status + done_at: datetime | None = None + + def complete(self, at: datetime) -> Task: + if self.status is Status.DONE: + return self + return self.model_copy(update={"status": Status.DONE, "done_at": at}) + + def is_open(self) -> bool: + return self.status is Status.OPEN + + +def open_slugs(tasks: list[Task]) -> list[Slug]: + return [t.slug for t in tasks if t.is_open()] + + +def by_status(tasks: list[Task]) -> dict[Status, int]: + return dict(Counter(t.status for t in tasks)) + + +def find(tasks: list[Task], task_id: UUID) -> Task: + if (found := next((t for t in tasks if t.id == task_id), None)) is None: + raise NotFoundError(f"task {task_id}") + return found diff --git a/.agents/skills/traust-core-patterns/SKILL.md b/.agents/skills/traust-core-patterns/SKILL.md index 7ed6edd0..e843b561 100644 --- a/.agents/skills/traust-core-patterns/SKILL.md +++ b/.agents/skills/traust-core-patterns/SKILL.md @@ -1,17 +1,17 @@ --- name: traust-core-patterns -description: How to add code to traust-core the way the platform API expects. Use when adding or changing a repository, unit of work, artifact store, service interface, provider (tool, source, feed), report, router, config field, or when making a breaking change that needs a v2. +description: How to add code to traust-core the way the platform API expects. Use when adding or changing a repository, unit of work, artifact store, service interface, provider (tool, source, feed), report, router or config field. Read AGENTS.md → Design rules first. --- # traust-core patterns -Rules live in `AGENTS.md`. This skill is the recipe for each kind of change. Copy the worked example; don't invent a new shape. +Rules live in `AGENTS.md` → **Design rules**: one owner per capability, one service per domain, behaviour placed by responsibility, no cross-domain repository access or joins, typed IDs. This skill is the recipe for each kind of change. Copy the worked example; don't invent a new shape. | Adding | Recipe | Worked example | |---|---|---| -| An aggregate repository over a contracts table | [repository.md](repository.md#aggregate-repository) | `src/traust_core/v1/repositories/triage.py` | +| An aggregate repository over a contracts table | [repository.md](repository.md#aggregate-repository) | `src/traust_core/v1/repositories/triage.py` (mechanics only; legacy ids) | | A table and its repository | [repository.md](repository.md) | `tests/v1/example/repository.py`; over a contracts table: `src/traust_core/v1/repositories/storage.py` | -| A service function or interface | [service.md](service.md) | `src/traust_core/v1/services/storage.py` | +| A domain service, policy or interface | [service.md](service.md) | `src/traust_core/v1/services/portfolio.py` (shape) | | A tool, source or feed provider | [provider.md](provider.md) | `tests/v1/example/provider.py` | | An artifact type the harness/LLM produces | [artifacts.md](artifacts.md) | `tests/v1/example/artifact_publishing.py` (`TRIAGE`) | | A job the engine runs or an LLM triggers | [service.md](service.md#job-engine-and-llm-entry) | `tests/v1/example/continuous_ops/` | @@ -19,6 +19,7 @@ Rules live in `AGENTS.md`. This skill is the recipe for each kind of change. Cop | A router | [report.md](report.md#router) | `tests/v1/example/router.py` | | A config field | [service.md](service.md#config) | `src/traust_core/v1/context/` | | A contracts bump or a named artifact | [contracts.md](contracts.md) | `src/traust_core/v1/models/artifacts.py`, `tests/v1/models/test_artifacts.py` | -| A breaking change | [versioning.md](versioning.md) | — | + +Python style (typing, models, errors, idioms): [../python-patterns/SKILL.md](../python-patterns/SKILL.md). Finish every change with `make lint-fix && make test`. diff --git a/.agents/skills/traust-core-patterns/provider.md b/.agents/skills/traust-core-patterns/provider.md index 865ab36f..7759544a 100644 --- a/.agents/skills/traust-core-patterns/provider.md +++ b/.agents/skills/traust-core-patterns/provider.md @@ -4,7 +4,7 @@ 2. Provider class with `name`, `check() -> Readiness`, and `acquire(request) -> Result[T]` (or the source/feed methods in `providers/sources.py`, `providers/feeds.py`). 3. Constructor takes what it needs (`ProcessRunner`, `Clock`, ...); never builds them. 4. Tools: build argv from typed fields, `--` before user values, run through `ProcessRunner`, parse output into models. -5. Every result carries `Provenance` (provider, version, source, acquired_at, stale). +5. Every result carries `Acquisition` (provider, version, source, acquired_at, stale). 6. `check()` returns `Readiness(ready=False, detail=...)` instead of raising. 7. Register at startup: `ctx.tools.register("", lambda: Provider(ctx.process))`. 8. Generic providers (git, forges) live in core; security tools and feeds live in the consuming package. diff --git a/.agents/skills/traust-core-patterns/repository.md b/.agents/skills/traust-core-patterns/repository.md index 809bf9d6..c963fd6b 100644 --- a/.agents/skills/traust-core-patterns/repository.md +++ b/.agents/skills/traust-core-patterns/repository.md @@ -1,7 +1,7 @@ # Repository 1. Model in `models/.py`: `Entity`/`Model` subclass or frozen dataclass. No database types. -2. Interface: a `Protocol` with only the methods callers need (`add`, `get`, `list_by_`, ...). No generic CRUD. +2. Interface: a `Protocol` over one aggregate, in `repositories/.py`, using the shared verbs only as needed: `get(id) -> T` (raises `NotFoundError`), `find_*(...) -> T | None`, `list(filter, page) -> Page[T]`, `add(T) -> T` and `update(T) -> T` returning the saved record (database-generated `id`, `created_at`, `updated_at` via `RETURNING`). IDs are `UUID`, named `_id`. No generic CRUD base, no cross-domain tables. 3. Unit-of-work protocol: `class XUnitOfWork(UnitOfWork, Protocol): xs: XRepository`. 4. SQL implementation: - `sa.Table(...)` describing the existing table (DDL is owned by traust-contracts) @@ -12,19 +12,23 @@ 6. In-memory double implementing the same interface, raising the same errors. 7. Contract tests parametrized over `["memory", *SQL_BACKENDS]` using `tests/v1/conftest.py::database`. The Postgres case is marked `integration` and runs with `make db-up && make test-integration`. -Service functions take the unit of work: +The domain service takes the unit of work in its constructor and opens it per use case: ```python -def complete(uow: TaskUnitOfWork, task_id: str) -> Task: - with uow: - task = uow.tasks.get(task_id) - ... - uow.commit() +class TaskService: + def __init__(self, uow: TaskUnitOfWork) -> None: + self._uow = uow + + def complete(self, task_id: UUID) -> Task: + with self._uow as uow: + done = uow.tasks.update(uow.tasks.get(task_id).complete()) # rule on the model + uow.commit() + return done ``` ## Aggregate repository -One repository per aggregate (the thing the domain asks questions about), not per table. Worked example: `repositories/triage.py`, written by `services/findings/decisions.py::record_triage`. +One repository per aggregate (the thing the domain asks questions about), not per table. `repositories/triage.py` shows the SQL mechanics (table, mapping, `schema_drift`, idempotency); its string ids and `record_triage` free function are legacy and get replaced in the Findings slice — copy the mechanics, not the shape. 1. Domain model in `models/.py` (`TriageVerdict` in `models/findings.py`): fields = the projection's columns, typed. 2. Protocol with the domain questions only (`for_binding`, `for_source_finding`, `true_positives`); no generic CRUD. diff --git a/.agents/skills/traust-core-patterns/service.md b/.agents/skills/traust-core-patterns/service.md index 288c6f40..d80f8371 100644 --- a/.agents/skills/traust-core-patterns/service.md +++ b/.agents/skills/traust-core-patterns/service.md @@ -1,17 +1,21 @@ -# Service functions and interfaces +# Services and interfaces -Service functions (the default): +Rules: `AGENTS.md` → Design rules. Shape to copy: any domain service, e.g. `services/portfolio.py`. -1. `services/.py`: plain functions taking a unit of work (or a repository) plus `Clock`; see `services/storage.py::record_artifact`. -2. Validate input first (models, value objects), then open the unit of work, call repositories, commit. -3. Return `Dto`s or domain `Model`s, never `dict`. -4. Contract tests run the function against every unit-of-work implementation. +A domain service: -Interfaces (when traust-engine or traust-ledger owns the implementation): +1. One class per domain in `services/.py` (a package only when the domain holds several responsibilities, e.g. `services/findings/`). Before adding a method, check no other service already owns the capability. +2. Constructor takes its unit of work, the other domains' **services** it needs (never their repositories), providers and `Clock`. +3. Methods take typed IDs, commands or contracts artifacts and return domain models or `Page[...]` — never `dict`, never `JobResult`. +4. Skill doors: `prepare(...) -> Input` (extends `SkillInput`) and `record(doc: Artifact, provenance: Provenance)`; `@overload` when the domain records more than one document. Run the document's `Lint` rules inside `record`. +5. Reads use the shared verbs: `get`, `find_*`, `list(filter, page)`, `summary(filter, group_by)`, `timeline(filter, bucket)`. +6. Behaviour about one entity's own state goes on the model; rules needing config or several domains go in a policy class next to the service. +7. Contract tests run the service against every unit-of-work implementation. -1. Request/response `Dto`s in `models/.py`; the `Protocol` in the service module that consumes it. -2. The owning repo implements it in-process; core never calls over HTTP. -3. Export from `services/__init__.py`; README row. +Interfaces implemented outside core (`Materializer`, `Router`): + +1. Request/response models in `models/.py`; the `Protocol` in the service module that consumes it. +2. Implemented in the owning repo; export from `services/__init__.py`; README row. ## Config @@ -22,8 +26,8 @@ Interfaces (when traust-engine or traust-ledger owns the implementation): ## Job (engine and LLM entry) -1. The work is a service function: takes a unit of work, providers, `Clock`; returns `JobResult` (`succeeded`, `partly(degraded)`, `refused`, `failed`). Never raises on source trouble; report it as degraded. -2. `Materializer` (`services.operations`): `asset` name + `materialize(AssetRequest) -> JobResult`. Parse partition/params into value objects; bad input → `JobResult.failed`; then call the one service. +1. The work is a domain service method returning a domain result. +2. `Materializer` (`services.operations`): `asset` name + `materialize(AssetRequest) -> JobResult`. Parse partition/params into value objects; bad input → `JobResult.failed`; call the one service; turn its result (or degraded/refused outcome) into `JobResult`. No logic beyond that. 3. CLI for LLMs: argv → `AssetRequest` → the same materializer → print `JobResult` JSON → exit code. 4. traust-engine registers materializers at startup: `ctx.assets.register(name, factory)`. -5. Worked example: `tests/v1/example/continuous_ops/`. +5. Worked example: `tests/v1/example/continuous_ops/` (its service is a module function — predates the one-service-per-domain rule; copy the adapter, not that). diff --git a/.agents/skills/traust-core-patterns/versioning.md b/.agents/skills/traust-core-patterns/versioning.md deleted file mode 100644 index 055cebfb..00000000 --- a/.agents/skills/traust-core-patterns/versioning.md +++ /dev/null @@ -1,13 +0,0 @@ -# Versioning - -Additive (stays in `v1`): new module, new optional field, new method, new provider, new view row type. - -Breaking (needs `v2`): rename, removed field, changed signature, changed behaviour, required field added. - -For a breaking change: - -1. Create `src/traust_core/v2//` with only the changed modules; import the rest from `v1`. -2. Mirror tests in `tests/v2/`. -3. Leave `v1` untouched and importable. -4. `v2` may import `v1`; `v1` never imports `v2`. -5. README: note the new version and what moved. diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..7fa74c3d --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Conventional commit subject: type(scope): subject. Bypass: git commit --no-verify +set -euo pipefail +subject="$(head -n1 "${1:?message file required}")" +pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([^)]+\))?!?: .+' +if [[ "$subject" =~ ^(Merge|Revert|fixup!|squash!) ]] || [[ "$subject" =~ $pattern ]]; then exit 0; fi +echo "commit-msg: non-conventional subject: '$subject'" >&2 +echo " expected: type(scope): subject (e.g. feat(findings): add intake door)" >&2 +exit 1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..7314e068 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Fast lint on staged .py files. Bypass: git commit --no-verify +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +command -v uv >/dev/null 2>&1 || { echo "pre-commit: uv not found — install uv, then run: make setup" >&2; exit 1; } +staged="$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.py$' || true)" +[ -z "$staged" ] && exit 0 +uv run ruff check $staged +uv run ruff format --check $staged diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..d71df507 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Full lint + unit tests with the coverage gate (same as CI). Bypass: git push --no-verify +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +command -v uv >/dev/null 2>&1 || { echo "pre-push: uv not found — install uv, then run: make setup" >&2; exit 1; } +make lint +make coverage diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37adb396..cd54a3c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: enable-cache: true python-version: ${{ matrix.python-version }} - run: uv sync --frozen - - run: make test + - run: make coverage # same tests as make test, fails under 80% (pyproject fail_under) postgres: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 5b5cfca7..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,41 +0,0 @@ -# Tier 2: manual release (XWING-2207 AC#3). Self-versioned — no package index yet. -# -# traust-core is versioned by the VERSION file (kept equal to pyproject.toml) and -# released with the house release.py: test -> `uv build` -> create the vX.Y.Z git -# tag. There is no package index; consumers pin the git tag/rev (as traust-core -# pins traust-contracts today). traust-platform-api.md calls for moving to an -# index so consumers can use `>=` — wire `uv publish` here once that's decided. -# -# To cut a release: bump in a PR (`make bump patch|minor|major`), merge, then run -# this workflow. Default build+tag is local to the run; set push=true to publish -# the tag to origin. -name: Release - -on: - workflow_dispatch: - inputs: - push: - description: "Push the vX.Y.Z tag to origin" - type: boolean - default: false - -jobs: - release: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 # release.py inspects existing tags - - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - python-version: "3.12" - - run: uv sync --frozen - - name: Release (check + test + build + tag) - run: ./release.py release ${{ inputs.push && '--push' || '' }} - - uses: actions/upload-artifact@v4 - with: - name: traust-core-dist - path: dist/* diff --git a/AGENTS.md b/AGENTS.md index 438afac3..7ca78f47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,15 +2,35 @@ Python 3.11+ library. The platform API lives in `traust_core.v1`. Overview: [README.md](README.md). +## Design rules (read first — these override convenience) + +- **One owner per capability.** Never implement a capability twice. Inconsistent data always means a second owner: remove it, never sync it. +- **Less is the goal.** One service per domain owns its use cases (reads and writes). Split a service only when it holds two responsibilities (e.g. `services/findings/`). No speculative helpers, no generic bases. +- **Place behaviour by responsibility:** + - model: rules about one entity's own state (`Disposition.admit`, `Repository.risk_tier`) + - policy: rules needing config or spanning domains (`RatingPolicy`, `IdentityPolicy`) + - service: use-case orchestration + - repository: persistence, reads and writes, its own domain's tables only + - provider: anything outside the process (tools, forges, feeds, ledger, identity) +- **Domain boundaries.** A service never touches another domain's repositories; it calls that domain's service. Several repositories of your own domain in one service are fine — the line is whose repositories, not how many. Repositories never join across domains. Callers compose across domains by typed ID; composite reads are their own query object (`RepoStateQuery`). +- **The database owns identity and time.** Every record has `id` (UUID), `created_at`, `updated_at`, generated by the database on insert (`RETURNING`). `id is None` = not saved yet; `add` returns the saved record. The app never mints IDs or timestamps. IDs are plain `UUID`, named `_id` (fields and parameters, matching the DDL's `_id`); never a `str` ID, never a path as an ID, never a path in a signature. Don't re-declare `id` or add your own created/updated timestamps on an `Entity`. +- **Same shapes everywhere.** Repositories: `get`, `find_*`, `list`, `add`, `update` (`add`/`update` return the saved record). Skill doors: `prepare(...) -> Input` and `record(doc, provenance)` (`@overload` per document). Rollups: `summary(filter, group_by)`; series: `timeline(filter, bucket)`. Deterministic document checks: `Lint`. +- **Services return domain results.** `JobResult` exists only at the engine/CLI adapter boundary. +- **One code path.** Solo (`traust …`, in-process), small group (`traust serve --with-engine`, one process) and scale (REST API, engine and runners as separate processes) run the same services; only the transport (in-process or REST) and the storage (SQLite + dir, or Postgres + object storage) differ. Never add a mode-specific branch of domain logic; the REST API holds none. CI runs service tests in-process and through the REST adapter, plus one end-to-end skill on SQLite with no server +- **Before adding a symbol, find the existing owner.** Extend it; if it truly can't own the new thing, say why in the PR. `grep -rn "NEED(" src` first. + ## Agent files - `AGENTS.md` (this file): rules, always loaded. `CLAUDE.md` imports it for Claude Code - `.agents/skills/`: recipes loaded on demand (Agent Skills standard). `.claude/skills` is a symlink to it +- Writing Python here: load `python-patterns` (style, typing, errors) and `traust-core-patterns` (where code goes) before editing - Edit only `AGENTS.md` and `.agents/skills/`; never fork content into `CLAUDE.md` or `.claude/` ## Before done -- `make lint-fix` then `make test`; both must pass. Database changes also need `make db-up && make test-integration` +- First time in a clone: `make setup` (uv sync + git hooks via `core.hooksPath=.githooks`). Hooks: ruff on staged files (pre-commit), conventional subject (commit-msg), `make lint` + `make coverage` (pre-push). Don't bypass with `--no-verify` + +- `make lint-fix` then `make test`; both must pass. CI runs `make coverage`, which fails below 80% line+branch coverage (`fail_under` in `pyproject.toml`); stub bodies (`raise NotImplementedError`) are excluded, so new code must arrive with tests. Database changes also need `make db-up && make test-integration` - Tests that need Postgres are marked `@pytest.mark.integration`; `make test` excludes them - Commits: conventional `type(scope): subject` - Public repo: no deployment-specific hostnames or figures in code, tests or docs @@ -18,40 +38,56 @@ Python 3.11+ library. The platform API lives in `traust_core.v1`. Overview: [REA ## Versioning - Everything public is under `traust_core/v1/`; tests mirror it under `tests/v1/` -- Within `v1`, changes are additive only: new modules, new optional fields, new methods -- A breaking change (rename, removed field, changed signature or behaviour) goes into a new `traust_core/v2/` package; `v1` stays until its consumers move -- `v2` may import `v1`; `v1` never imports `v2` +- `v1` is not frozen yet: the platform API is reshaped in place +- No release machinery (no tags, CHANGELOG, VERSION bumps): consumers pin a git commit SHA, as core pins traust-contracts ## Layers - `errors` → `contracts` → `models` → `providers` → `repositories` → `rendering` → `services` → `context`; each imports only earlier ones (`tests/v1/test_layering.py`) -- One question per folder: `models` what it is (data only), `providers` how we reach outside (tools, sources, feeds, process, clock), `repositories` how it is stored, `services` what we do with it (use cases + pure policies), `context` how it is wired +- One question per folder: `models` what it is and the rules about its own state, `providers` how we reach outside (tools, sources, feeds, process, clock), `repositories` how it is stored, `services` what we do with it (use cases + pure policies), `context` how it is wired - Organised by role, then subject: `models/.py`, `repositories/.py`, `services/.py`; a subject becomes a package only when it needs several modules (`services/findings/`) -- `models/base.py`: `Model` (strict, internal), `Dto` (tolerant, boundary data), `Entity` (`id: UUID`, minted by the owning service via `new_id`) +- `models/base.py`: `Model` (strict, internal), `Dto` (tolerant, boundary data), `Entity` (`id`, `created_at`, `updated_at` — `None` until the database saves it) - Tests mirror `src`: `tests/v1//test_.py` - Pack policy (routing rules, cadences, lanes, skills, CLIs) stays in traust -- No imports from other traust repos -- No HTTP. In-process library for traust, traust-engine and traust-ledger; an SDK comes later +- traust-core is the hub: every other traust repo depends on it, it depends on none of them. One exception: the traust-ledger client, imported only inside the ledger provider adapter (behind `LedgerClient`). traust-contracts is data, read through `traust_core.v1.contracts` +- HTTP is in scope, in two places only: outbound calls live inside providers (forge, feeds, advisory APIs), and the inbound REST API +- **REST API:** a real service in front of the core services, with **no domain logic of its own** (rules live in the services, so the in-process path behaves the same). It must follow: + - resources addressed by ID: `/findings/{finding_id}`, `/findings/{finding_id}/decisions`, `/orders/{order_id}/lease`; plural nouns, verbs only as HTTP methods + - request and response bodies are the core models (R17), with an OpenAPI spec generated from them + - one error shape everywhere (`application/problem+json`: type, title, detail, request ID), mapped from `traust_core.v1.errors` + - lists are paged with a cursor (`Page`); filters are query parameters on the collection + - every request carries a request ID (accept `X-Request-ID` or W3C `traceparent`, else mint one), returned in the response and in every log line it causes + - one structured access-log line per request: method, route, status, duration, request ID, caller ## Contracts - traust-contracts is a pinned dependency; read its data through `traust_core.v1.contracts` only, never import `traust_contracts` - Nothing from traust-contracts is copied or generated into this repo - Artifacts are named types in `models/artifacts.py` (`TriageArtifact`, ...); their fields come from the installed schema, never a hand-written copy -- Read with `AnalysisResultsRepository.read(XArtifact, subject)`, write with `ArtifactPublisher` (exact schema gate); no `json.load`/`.get()`/`json.dump` on artifacts +- Write with `ArtifactPublisher` (exact schema gate); read with `ArtifactPublisher.get(ArtifactId)`. `AnalysisResultsRepository.read(XArtifact, subject)` is legacy (path-keyed) until the Artifacts slice. No `json.load`/`.get()`/`json.dump` on artifacts - Names: `models/artifacts.py` = what an artifact is; `repositories/object_store.py` = raw bytes; `services/artifact_publishing.py` = the publish use case. Modules name the subject; the package names the role (no `_service` suffix) - Every repository `sa.Table` over a contracts table has a `schema_drift` test - Artifacts produced by the harness or an LLM go through `ArtifactPublisher`; nothing writes `analysis-results` files directly - Protocols implemented outside core (`Materializer`, `Router` in `services/operations.py`) exist only for a decided pattern or backlog item; each gets an implementation in the repo that owns it -- Work is a service. A `Materializer` (for traust-engine) and a CLI command (for LLMs) are thin adapters that validate input, call one service and return its `JobResult`; no logic in either +- Work is a service. Services return domain results; a `Materializer` (for traust-engine) and a CLI command (for LLMs) are thin adapters that validate input, call one service and turn its result into a `JobResult`; no logic in either ## Coding practices **Comments and docs** -- The code explains itself: clear names, small functions, types. No narrating comments, no docstrings that restate the signature -- Comment only a non-obvious *why* (a workaround, a constraint), in one line +- The code explains itself: clear names, small functions, types +- Docstrings: optional, **1–2 lines max**, saying what something is for when the name can't. Never restate the signature, never list args/returns +- **Bloated comments and over-explaining are not allowed, at all.** No narrating what the code does, no multi-line comment blocks, no section banners, no commented-out code. If code needs explaining, rename or restructure it +- The one exception: a non-obvious *why* (a workaround, an external constraint), in one line +- `# NEED():` marks an agreed platform need that is not built yet (up to 3 lines, the only multi-line comment allowed); `grep -rn "NEED(" src` before starting a slice and delete the marker when it lands - API overviews belong in the README, not in module docstrings +**Logging and tracing** (you must be able to follow any request, run or order end to end) +- One logging setup, in `context`, driven by `Config`: level per logger, format (JSON for servers, text for a terminal), stderr plus a rolling file (size and retention configured). Nothing else calls `logging.basicConfig` or adds handlers +- Every entry point (REST request, CLI command, engine order, runner job) sets a correlation ID in a context variable. Every log line carries it, along with the order or run ID and the actor when known. Providers pass it on: an HTTP header on outbound calls, an env var for subprocesses +- Levels: ERROR = someone must act; WARNING = degraded or refused; INFO = state changes (record written, order moved, external call made, with duration); DEBUG = detail for diagnosis +- Log at boundaries (entry points, provider calls, repository failures), not inside every function. Log an error once, where it is handled; never log and carry on as if it succeeded +- Never log secrets, tokens or document bodies; log IDs, counts and digests instead + **Types and data** - Type hints on every signature - `Model` for internal objects (strict), `Dto` for boundary data (tolerant); frozen dataclasses for value objects @@ -62,7 +98,7 @@ Python 3.11+ library. The platform API lives in `traust_core.v1`. Overview: [REA **Structure** - Repository pattern: one repository per aggregate, written on SQLAlchemy Core; no ORM mapped classes, no generic CRUD base - Domain objects never know about the database; rows map to models only inside repositories -- Service functions receive a unit of work, a service or a provider; they never open connections, build paths, read env vars or start processes +- Services receive units of work, other services and providers through their constructor; they never open connections, build paths, read env vars or start processes - `Context` only at entry points; pass the specific dependency, not `Context` - No `subprocess` outside `ProcessRunner`, no clock reads outside `Clock` - Interfaces are `Protocol`s defined where they are consumed @@ -76,6 +112,7 @@ Python 3.11+ library. The platform API lives in `traust_core.v1`. Overview: [REA - Every repository, store and provider has contract tests that run against each implementation (memory, sqlite, Postgres when `TRAUST_TEST_DATABASE_URL` is set) - Pure functions get table tests - Test names say the behaviour they protect +- A ported legacy scenario that belongs to a later slice is still written now, in its owner's test module, marked `@pytest.mark.todo("", legacy="/tests/::")` (skipped until that slice removes the marker). Never drop a legacy scenario silently - Worked examples in `tests/v1/example/` are the pattern to copy; keep them current **Scope** diff --git a/Makefile b/Makefile index e8d1e25e..770493f9 100644 --- a/Makefile +++ b/Makefile @@ -9,24 +9,27 @@ export TRAUST_TEST_DATABASE_URL PYTHON ?= python3 -RELEASE := ./release.py -BUMP_PARTS := patch minor major -.PHONY: help setup sync lint lint-fix test test-integration coverage coverage-html coverage-all db-up db-down status bump $(BUMP_PARTS) +.PHONY: help setup sync hooks lint lint-fix test test-integration coverage coverage-html coverage-all db-up db-down help: @echo "Targets ($(notdir $(CURDIR))):" - @echo " make setup — uv sync (run once per clone)" + @echo " make setup — uv sync + install git hooks (run once per clone)" @echo " make lint — ruff check + format --check" @echo " make lint-fix — ruff --fix + format" @echo " make test — unit tests (no integration)" @echo " make test-integration — integration tests against Postgres (make db-up first)" - @echo " make coverage — unit tests with coverage (term)" + @echo " make coverage — unit tests with coverage (term), fails under 80%" @echo " make coverage-html — unit tests with coverage (term + htmlcov/)" @echo " make coverage-all — unit + integration with coverage (needs make db-up)" @echo " make db-up / db-down — shared Postgres container ($(DB_CONTAINER)), same as contracts and ledger" -setup: sync +setup: sync hooks + @echo "ready — git hooks enabled (.githooks): lint on commit, conventional subject, lint + coverage on push" + +hooks: + git config core.hooksPath .githooks + @chmod +x .githooks/* 2>/dev/null || true sync: uv sync @@ -78,17 +81,3 @@ db-up: db-down: @podman stop $(DB_CONTAINER) 2>/dev/null || true -status: - $(PYTHON) $(RELEASE) status - -$(BUMP_PARTS): - @: - -bump: - @part="$(filter $(BUMP_PARTS),$(MAKECMDGOALS))"; \ - if [ -z "$$part" ]; then \ - echo "usage: make bump patch|minor|major" >&2; \ - exit 1; \ - fi; \ - $(PYTHON) $(RELEASE) bump $$part - diff --git a/README.md b/README.md index 0c4681d0..bea931c9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # traust-core -The shared Python base for traust: one clear place to read and write each kind of data, so scripts stop opening their own database connections, building their own paths, writing their own JSON and running their own subprocesses. It is an in-process library for traust (the harness), traust-engine and traust-ledger. No HTTP; an SDK comes later, when needed. +The shared Python base for traust: one clear place to read and write each kind of data, so scripts stop opening their own database connections, building their own paths, writing their own JSON and running their own subprocesses. Python callers (traust, traust-engine, traust-ledger) use it in-process; other languages (the Go SDK, SCI) will reach the same services through the REST API served from here (resource/ID routes, request IDs, OpenAPI generated from the core models). All modules live under `traust_core.v1` (see [Versioning](#versioning)). @@ -8,7 +8,7 @@ All modules live under `traust_core.v1` (see [Versioning](#versioning)). |---|---|---|---| | **Contracts** | `contracts` | the pinned traust-contracts dependency, read as data: `validate(name, doc)` (exact JSON Schema gate), `schema()`, `enum_values()`, `normalize_enum(name, value)` (registry read view), `ddl()`, `storage_profile()` | skills remembering to run `reporting validate` | | **Errors** | `errors` | the exception hierarchy every layer raises (`ValidationError`, `NotFoundError`, …) | ad hoc exceptions | -| **Models** | `models` | what things are, data only: `base` (`Model` strict, `Dto` tolerant, `Entity` with a UUID id), `values` (`HttpsRepoUrl`, `GitSha`, `CveId`), `integrity`, `storage`, `operations` (`JobResult`, `AssetRequest`, `Order`), and one module per subject (`findings`, `portfolio`, …) | dicts passed between scripts | +| **Models** | `models` | what things are and the rules about their own state: `base` (`Model` strict, `Dto` tolerant, `Entity` with a UUID id), `values` (`HttpsRepoUrl`, `GitSha`, `CveId`), `integrity`, `storage`, `operations` (`JobResult`, `AssetRequest`, `Order`), and one module per subject (`findings`, `portfolio`, …) | dicts passed between scripts | | **Artifacts** | `models.artifacts` | the `Artifact` base + `SchemaView` (fields from the installed schema, `doc.findings[0].verdict`, unknown names raise) and the named documents (`TriageArtifact`, `SecurityAuditArtifact`, …, `Verdict`, `Severity`) | `json.load` + `.get("key")` | | **Artifact publishing** | `services.artifact_publishing` | `ctx.artifact_publisher(specs).publish(name, subject, raw, run_id)` (LLM) or `.publish_artifact(artifact, subject, run_id)` (code): exact gate → files (always) → index (when `database.url` is set) | writing `analysis-results` files by hand | | **Analysis results** | `repositories.analysis_results` | `ctx.analysis_results().read(TriageArtifact, subject)` / `.read_all(TriageArtifact, tree)`; raw `put/get/find(subject, kind)`; today's naming convention in one place | `json.load` + `"-security-audit.json"` path building | @@ -78,7 +78,7 @@ All in [`tests/v1/example/`](tests/v1/example): | File | Shows | |---|---| -| `model.py`, `repository.py`, `services.py` | domain model, repository (SQL + in-memory), unit of work, service functions | +| `model.py`, `repository.py`, `services.py` | domain model, repository (SQL + in-memory), unit of work, service (example predates the one-service-per-domain rule) | | `artifact_publishing.py` | a publish spec (`TRIAGE` over `TriageArtifact`) with a rendered markdown companion | | `report.py` | read through a repository → pure `build_report` → render → publish | | `router.py` | a pure router, table-tested | @@ -146,21 +146,21 @@ sibling checkout. No dependency pin or historical payload changes are made. | **Core** | *what* the data means and *how* it is stored, read and run: models, repositories, services, policies | this repo, by role: `models/.py`, `repositories/.py`, `services/.py` (`services/findings/` is a package) | | **Pack policy** | *when / which*: routing rules, cadences, lanes, skills, CLIs | traust (`traust_pack_security`), not this repo | -Everything here is stable `v1` API (additive only). A concrete repository appears per **aggregate** when there is a domain question to answer; its methods are those questions (see `repositories/triage.py`). Tests mirror `src`: `tests/v1//test_.py`. +Everything here is `v1` API, reshaped in place until it is frozen. A concrete repository appears per **aggregate** when there is a domain question to answer; its methods are those questions (see `repositories/triage.py`). Tests mirror `src`: `tests/v1//test_.py`. ## Rules -- Service functions never open connections, build paths, start processes or read env vars; they receive what they need. +- Services never open connections, build paths, start processes or read env vars; they receive what they need through their constructor. - `Context` at entry points only. - Data is parsed into a model once, at the edge. No bare `dict` across a function boundary. - SQLAlchemy Core only: no ORM mapped classes, no generic CRUD base. Domain objects never know about the database. - Unknown or unmeasured values are `None`, never `0`. - Every repository, store and provider has contract tests. -- No imports from other traust repos. No HTTP. +- traust-core is the hub: other traust repos depend on it, not the reverse. The only code dependency is the traust-ledger client, inside the ledger provider adapter; traust-contracts is pinned data. HTTP only in providers (outbound) and the HTTP adapter (inbound). ## Domain stubs (not implemented) -Per subject: `models/.py`, `repositories/.py` (`Protocol` + unit of work) and `services/.py`. Every method raises `NotImplementedError("stub: replaces …")` naming the code it replaces. Rules: every repository and service lives here; traust-engine only runs work; the LLM reads and writes documents only; identity is a UUID minted by the owning service (`models.base.new_id`), the fingerprint is only a match key. +Per subject: `models/.py`, `repositories/.py` (`Protocol` + unit of work) and `services/.py`. Every method raises `NotImplementedError("stub: replaces …")` naming the code it replaces. Rules: every repository and service lives here; traust-engine only runs work; the LLM reads and writes documents only; identity is a database-generated UUID with `created_at` / `updated_at` (`None` until saved), the fingerprint is only a match key. | Domain | Module | Service | Repository | |---|---|---|---| @@ -186,16 +186,15 @@ Insight (dashboards) has no service: views read repositories and render. Reposit | Fingerprint rules (shape fixed; raises until the D5 spec lands) | `models.integrity`, `services.findings.identity` | | First real providers (git, forges, feeds) | packages | | S3 object store backend | `repositories.object_store` | -| CI + release | — | ## Versioning -`traust_core.v1` is the platform API. Within `v1`, changes are additive only. A breaking change goes into a new `traust_core.v2` package alongside `v1`; `v1` stays importable until its consumers move. `v2` may import `v1`; `v1` never imports `v2`. +`traust_core.v1` is the platform API, reshaped in place until it is frozen. There are no releases: consumers pin a git commit SHA (`[tool.uv.sources] traust-core = { git = "…", rev = "" }`), the same way core pins traust-contracts. ## Development ```bash -make setup # uv sync (installs the pinned traust-contracts) +make setup # uv sync + git hooks (lint on commit, conventional subject, lint + coverage on push) make lint-fix make test # unit tests make coverage # + coverage (coverage-html for htmlcov/) diff --git a/VERSION b/VERSION deleted file mode 100644 index 6e8bf73a..00000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.1.0 diff --git a/pyproject.toml b/pyproject.toml index c1fc7888..b2790bc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,10 @@ exclude = ["/.ci-venv", "/.venv"] [tool.pytest.ini_options] testpaths = ["tests"] -markers = ["integration: requires Postgres (make db-up; TRAUST_TEST_DATABASE_URL)"] +markers = [ + "integration: requires Postgres (make db-up; TRAUST_TEST_DATABASE_URL)", + "todo(slice, legacy): ported legacy scenario parked for a later slice; skipped until that slice enables it", +] [tool.coverage.run] source = ["traust_core"] @@ -46,6 +49,7 @@ branch = true parallel = true [tool.coverage.report] +fail_under = 80 show_missing = true skip_covered = false exclude_lines = [ diff --git a/release.py b/release.py deleted file mode 100755 index 235cab6f..00000000 --- a/release.py +++ /dev/null @@ -1,328 +0,0 @@ -#!/usr/bin/env python3 -"""Tag and release helper for this repo. Operates on cwd only. - -For workspace-wide stack releases, use tools/release.py in the mono-checkout. -""" - -from __future__ import annotations - -import os -import re -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path - -VERSION_FILE = "VERSION" -PYPROJECT_FILE = "pyproject.toml" -VERSION_ARGS_FILE = "version.args" -SEMVER = re.compile(r"^\d+\.\d+\.\d+$") -VERSION_LINE = re.compile(r"(?m)^version = \"[^\"]+\"") -PYPROJECT_VERSION = re.compile(r"(?m)^version = \"([^\"]+)\"") -BUMP_PARTS = frozenset({"major", "minor", "patch"}) - - -class ReleaseError(Exception): - pass - - -def semver_tuple(version: str) -> tuple[int, int, int]: - return tuple(map(int, version.split("."))) - - -def bump_version(current: str, part: str) -> str: - if part in BUMP_PARTS: - major, minor, patch = map(int, current.split(".")) - if part == "major": - return f"{major + 1}.0.0" - if part == "minor": - return f"{major}.{minor + 1}.0" - return f"{major}.{minor}.{patch + 1}" - if SEMVER.match(part): - return part - raise ReleaseError(f"bump must be major, minor, patch, or X.Y.Z; got {part!r}") - - -# --- Git --- - - -@dataclass(frozen=True) -class GitOpts: - force: bool = False - allow_dirty: bool = False - - -class Git: - def __init__(self, cwd: Path, opts: GitOpts | None = None) -> None: - self.cwd = cwd - self.opts = opts or GitOpts() - - def _run( - self, - *args: str, - check: bool = True, - capture: bool = False, - ) -> subprocess.CompletedProcess[str]: - return subprocess.run(args, cwd=self.cwd, check=check, text=True, capture_output=capture) - - def status(self) -> str: - return self._run("git", "status", "--porcelain", capture=True).stdout.strip() - - def branch(self) -> str: - return self._run("git", "rev-parse", "--abbrev-ref", "HEAD", capture=True).stdout.strip() - - def head(self) -> str: - return self._run("git", "rev-parse", "--short", "HEAD", capture=True).stdout.strip() - - def tags(self, pattern: str = "v*") -> list[str]: - out = self._run( - "git", "tag", "--list", pattern, "--sort=-v:refname", capture=True - ).stdout.strip() - return out.splitlines() if out else [] - - def tag_exists(self, tag: str) -> bool: - return ( - self._run( - "git", - "rev-parse", - "--verify", - f"refs/tags/{tag}", - check=False, - capture=True, - ).returncode - == 0 - ) - - def create_tag(self, tag: str, message: str) -> None: - if self.opts.force and self.tag_exists(tag): - self._run("git", "tag", "-d", tag) - self._run("git", "tag", "-a", tag, "-m", message) - - def push(self, *refs: str) -> None: - cmd = ["git", "push"] - if self.opts.force: - cmd.append("--force") - cmd.append("origin") - for ref in refs: - self._run(*cmd, ref) - - @property - def dirty(self) -> bool: - return bool(self.status()) - - def require_clean(self) -> None: - if not self.opts.allow_dirty and self.dirty: - raise ReleaseError("working tree is not clean") - - -# --- Repo --- - - -class Repo: - def __init__(self, path: Path, opts: GitOpts) -> None: - self.path = path - self.git = Git(path, opts) - - @property - def name(self) -> str: - return self.path.name - - def version(self) -> str: - vf = self.path / VERSION_FILE - pj = self.path / PYPROJECT_FILE - if not vf.is_file() or not pj.is_file(): - raise ReleaseError(f"missing {VERSION_FILE} or {PYPROJECT_FILE}") - v_file, text = vf.read_text().strip(), pj.read_text() - m = PYPROJECT_VERSION.search(text) - if not m: - raise ReleaseError(f"no [project] version in {PYPROJECT_FILE}") - if v_file != m.group(1): - raise ReleaseError(f"VERSION ({v_file}) != pyproject.toml ({m.group(1)})") - return v_file - - def set_version(self, version: str) -> None: - if not SEMVER.match(version): - raise ReleaseError(f"invalid semver: {version!r}") - self._require_tag_missing(version) - pj = self.path / PYPROJECT_FILE - text = pj.read_text() - if not VERSION_LINE.search(text): - raise ReleaseError(f"could not find version field in {PYPROJECT_FILE}") - (self.path / VERSION_FILE).write_text(f"{version}\n") - pj.write_text(VERSION_LINE.sub(f'version = "{version}"', text, count=1)) - # version.args feeds the container build-args param, and - # `check docs-consistency` requires it to equal VERSION. Bumping - # without it left every release failing that gate between the bump and - # a manual follow-up edit. Conditional: repos without the file are - # unaffected. - va = self.path / VERSION_ARGS_FILE - if va.is_file(): - va.write_text(f"VERSION={version}\n") - - def tag_name(self, version: str | None = None) -> str: - return f"v{version or self.version()}" - - def status(self) -> None: - v = self.version() - tags = self.git.tags() - tagged = self.git.tag_exists(self.tag_name(v)) - latest = tags[0] if tags else "none" - print( - f"{self.name}: {v} ({'tagged' if tagged else 'not tagged'}, " - f"tree {'dirty' if self.git.dirty else 'clean'}, " - f"branch {self.git.branch()}@{self.git.head()}, latest tag {latest})" - ) - - def check(self) -> None: - self.git.require_clean() - self._require_tag_missing(self.version()) - - def test(self) -> None: - env = os.environ.copy() - subprocess.run(("uv", "sync"), cwd=self.path, check=True, env=env) - subprocess.run( - ("uv", "run", "pytest", "tests/", "-q", "-m", "not integration"), - cwd=self.path, - check=True, - env=env, - ) - - def build(self) -> None: - subprocess.run(("uv", "build"), cwd=self.path, check=True) - - def tag(self, message: str | None = None) -> str: - v = self.version() - tag = self.tag_name(v) - self._require_tag_missing(v) - self.git.create_tag(tag, message or f"{self.name} {v}") - return tag - - def push(self) -> None: - v = self.version() - tag = self.tag_name(v) - if not self.git.tag_exists(tag): - raise ReleaseError(f"tag {tag} does not exist locally") - self.git.push(self.git.branch(), tag) - - def release( - self, - *, - skip_test: bool = False, - message: str | None = None, - push: bool = False, - ) -> str: - self.check() - if not skip_test: - print(f"==> test {self.name}") - self.test() - print(f"==> build {self.name}") - self.build() - tag = self.tag(message) - print(f"{self.name}: release ready at {tag}") - if push: - self.push() - print(f"{self.name}: pushed {tag}") - return tag - - def latest_tag_version(self) -> str | None: - tags = self.git.tags() - if not tags: - return None - tag = tags[0] - return tag[1:] if tag.startswith("v") else tag - - def release_if_ready(self, *, message: str | None = None, push: bool = False) -> str | None: - current = self.version() - latest = self.latest_tag_version() - - if self.git.tag_exists(self.tag_name(current)): - print(f"{self.name}: {self.tag_name(current)} already exists") - return None - - if latest is not None and semver_tuple(current) == semver_tuple(latest): - print(f"{self.name}: no pending release (VERSION {current} matches latest tag)") - return None - - if latest is not None and semver_tuple(current) < semver_tuple(latest): - raise ReleaseError(f"VERSION {current} is behind latest tag v{latest}") - - return self.release(skip_test=True, message=message or f"{self.name} {current}", push=push) - - def _require_tag_missing(self, version: str) -> None: - tag = self.tag_name(version) - if self.git.tag_exists(tag) and not self.git.opts.force: - raise ReleaseError(f"tag {tag} already exists") - - -# --- CLI --- - - -def main(argv: list[str] | None = None) -> int: - import argparse - - flags = argparse.ArgumentParser(add_help=False) - flags.add_argument("-m", "--message") - flags.add_argument("--skip-test", action="store_true") - flags.add_argument("--allow-dirty", action="store_true") - flags.add_argument("--push", action="store_true") - flags.add_argument("--force", action="store_true") - - p = argparse.ArgumentParser(description="Release helper (repo-local)", parents=[flags]) - sub = p.add_subparsers(dest="cmd", required=True) - - sub.add_parser("status", parents=[flags]) - bump = sub.add_parser("bump", parents=[flags]) - bump.add_argument("part", help="major, minor, patch, or X.Y.Z") - sub.add_parser("check", parents=[flags]) - sub.add_parser("test", parents=[flags]) - sub.add_parser("build", parents=[flags]) - sub.add_parser("tag", parents=[flags]) - sub.add_parser("push", parents=[flags]) - sub.add_parser("release", parents=[flags]) - sub.add_parser("release-if-ready", parents=[flags]) - - args = p.parse_args(argv) - opts = GitOpts(force=args.force, allow_dirty=args.allow_dirty) - repo = Repo(Path.cwd(), opts) - - try: - match args.cmd: - case "status": - repo.status() - case "bump": - current = repo.version() - new = bump_version(current, args.part) - if new == current: - print(f"{repo.name}: already at {current}") - else: - repo.set_version(new) - print(f"{repo.name}: bumped {current} -> {new}") - case "check": - repo.check() - print(f"{repo.name}: ok ({repo.tag_name()})") - case "test": - repo.test() - case "build": - repo.build() - print(f"{repo.name}: wheel built in dist/") - case "tag": - repo.git.require_clean() - print(f"{repo.name}: created {repo.tag(args.message)}") - case "push": - repo.push() - print(f"{repo.name}: pushed {repo.tag_name()}") - case "release": - repo.release(skip_test=args.skip_test, message=args.message, push=args.push) - case "release-if-ready": - repo.release_if_ready(message=args.message, push=args.push) - except ReleaseError as exc: - print(f"error: {exc}", file=sys.stderr) - return 1 - except subprocess.CalledProcessError as exc: - print(f"error: command failed ({exc.cmd})", file=sys.stderr) - return exc.returncode or 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/traust_core/v1/context/config.py b/src/traust_core/v1/context/config.py index 0954dd37..c009cf75 100644 --- a/src/traust_core/v1/context/config.py +++ b/src/traust_core/v1/context/config.py @@ -30,6 +30,7 @@ def password(self) -> str | None: return _read_env(self.password_env, "database.password_env") def engine_url(self) -> sa.URL: + # NEED(local): "files-only" goes away; with no url the record store is SQLite under home. if self.url is None: raise ConfigError("database.url is not set (files-only setup)") try: @@ -49,6 +50,11 @@ class ProcessConfig(_Model): timeout_seconds: float = 600.0 +# NEED(logging): one logging setup from Config — per-logger levels, JSON or text, stderr + rolling +# file, and a correlation ID set at every entry point and carried on every line (AGENTS.md). +# NEED(config): too busy and too clunky today. Target: one shape, one home, few keys — +# home dir, record store (SQLite default, Postgres optional), blob store (local dir or S3), +# identity, ledger. Pack data (catalogs, product maps) is not config; env only for secrets. class Config(_Model): database: DatabaseConfig = DatabaseConfig() artifacts: ArtifactsConfig = ArtifactsConfig() diff --git a/src/traust_core/v1/context/context.py b/src/traust_core/v1/context/context.py index 5d95ef04..76282271 100644 --- a/src/traust_core/v1/context/context.py +++ b/src/traust_core/v1/context/context.py @@ -33,6 +33,11 @@ U = TypeVar("U", bound=UnitOfWork) +# NEED(direction): the REST API is a thin adapter over Services, and the SDK becomes a +# framework (prepare -> run skill -> record) on top of that REST API, not thin wrappers. +class Services: ... + + class Context: def __init__( self, @@ -84,5 +89,8 @@ def artifact_publisher(self, specs: PublishSpecs) -> ArtifactPublisher: specs, AnalysisResultsRepository(self.object_store, ARTIFACT_FOR_KIND), index ) + def services(self) -> Services: + raise NotImplementedError("stub: composition root; wires every API service") + def analysis_results(self) -> AnalysisResultsRepository: return AnalysisResultsRepository(self.object_store, ARTIFACT_FOR_KIND) diff --git a/src/traust_core/v1/models/artifacts.py b/src/traust_core/v1/models/artifacts.py index 72e20cae..30a10143 100644 --- a/src/traust_core/v1/models/artifacts.py +++ b/src/traust_core/v1/models/artifacts.py @@ -215,6 +215,57 @@ class PrivProfileArtifact( ): ... +# Skill documents recorded through the domain services (R13); no analysis-results layout. +class AdapterResultArtifact(Artifact, name="adapter-result", schema="adapter-result"): ... + + +class IsolationReviewArtifact(Artifact, name="isolation-review", schema="isolation-review"): ... + + +class ImpactAnalysisArtifact(Artifact, name="impact-analysis", schema="impact-analysis"): ... + + +class PqcFactsArtifact(Artifact, name="pqc-facts", schema="pqc-facts"): ... + + +class PqcReadinessArtifact(Artifact, name="pqc-readiness", schema="pqc-readiness"): ... + + +class PqcBlockersArtifact(Artifact, name="pqc-blockers", schema="pqc-blockers"): ... + + +class PqcDecisionTreeArtifact(Artifact, name="pqc-decision-tree", schema="pqc-decision-tree"): ... + + +class RemediationArtifact(Artifact, name="remediation", schema="remediation"): ... + + +class FleetFixArtifact(Artifact, name="fleet-fix", schema="fleet-fix"): ... + + +class ComplianceScopeArtifact(Artifact, name="compliance-scope", schema="compliance-scope"): ... + + +class ComplianceAssessmentArtifact( + Artifact, name="compliance-assessment", schema="compliance-assessment" +): ... + + +class DocVarianceArtifact(Artifact, name="doc-variance", schema="doc-variance"): ... + + +class BenchmarkTargetArtifact(Artifact, name="benchmark-target", schema="benchmark-target"): ... + + +AssessmentArtifact = ( + SecurityAuditArtifact + | CloudConfigAuditArtifact + | ThreatModelArtifact + | PrivProfileArtifact + | IsolationReviewArtifact +) +PqcArtifact = PqcFactsArtifact | PqcReadinessArtifact | PqcDecisionTreeArtifact + ANALYSIS_ARTIFACTS: tuple[type[Artifact], ...] = ( SecurityAuditArtifact, FindingsCurrentArtifact, @@ -227,6 +278,3 @@ class PrivProfileArtifact( PrivProfileArtifact, ) ARTIFACT_FOR_KIND = {a.kind: a for a in ANALYSIS_ARTIFACTS if a.kind is not None} - -Verdict = contracts.enum("verdict") -Severity = contracts.enum("severity") diff --git a/src/traust_core/v1/models/assessment.py b/src/traust_core/v1/models/assessment.py index dd90b663..262d3d9c 100644 --- a/src/traust_core/v1/models/assessment.py +++ b/src/traust_core/v1/models/assessment.py @@ -2,35 +2,129 @@ from datetime import datetime from enum import StrEnum +from typing import NewType from uuid import UUID from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.common import ArtifactRef +from traust_core.v1.models.findings import Label, SkillInput +from traust_core.v1.models.vocabulary import ( + AuditProfile, + ChainSeverity, + IsolationPosture, + PeachBoundaryType, + TenancyProfile, + ThreatActor, + ThreatImpact, + ThreatLikelihood, + ThreatStatus, +) + +ToolName = NewType("ToolName", str) class AssessmentKind(StrEnum): - SECURE_CODE = "secure-code-audit" - CONTAINER = "secure-container-audit" - RPM = "secure-rpm-audit" - CLOUD_CONFIG = "cloud-config-audit" + SECURE_CODE = "secure-code" + CONTAINER = "container" + RPM = "rpm" + CLOUD_CONFIG = "cloud-config" VULN_SCAN = "vuln-scan" THREAT_MODEL = "threat-model" ISOLATION_REVIEW = "isolation-review" - PRIV_PROFILE = "operator-priv-profile" + PRIV_PROFILE = "priv-profile" PQC_READINESS = "pqc-readiness" + FUZZ = "fuzz" + + +# Shapes land with their implementation slice. +class CoverageGap(Model): ... + + +class AttackTechnique(Model): ... + + +class Permission(Model): ... + + +class IsolationCheck(Model): ... + + +class PopulationRow(Model): ... + + +class CoverageRow(Model): ... + + +class ToolRun(Model): ... + + +class AssessmentFilter(Model): ... + + +class ThreatFilter(Model): ... + + +class AssessmentInput(SkillInput): ... + + +class Coverage(Model): + examined: int + total: int + gaps: tuple[CoverageGap, ...] = () + + def ratio(self) -> float: + raise NotImplementedError("stub: replaces build_coverage_gap_rollup.build_rollup") class Assessment(Entity): revision_id: UUID kind: AssessmentKind + profile: AuditProfile | None = None + artifact: ArtifactRef assessed_at: datetime + coverage: Coverage | None = None class Threat(Entity): assessment_id: UUID - label: str + label: Label + title: str + status: ThreatStatus + actor: ThreatActor + likelihood: ThreatLikelihood + impact: ThreatImpact + boundary: PeachBoundaryType | None = None + techniques: tuple[AttackTechnique, ...] = () + + +class AttackChain(Entity): + assessment_id: UUID + threat_ids: tuple[UUID, ...] + severity: ChainSeverity + + +class PrivilegeProfile(Model): + assessment_id: UUID + permissions: tuple[Permission, ...] = () + tenancy: TenancyProfile + + +class IsolationResult(Model): + assessment_id: UUID + posture: IsolationPosture + checks: tuple[IsolationCheck, ...] = () class Freshness(Model): code_line_id: UUID - audit_age_days: int | None = None + last_assessed_at: datetime | None = None churn_ratio: float | None = None + + def age_days(self, now: datetime) -> int | None: + raise NotImplementedError("stub: replaces build_rescan_worklist audit_age_days") + + +class Population(Model): + as_of: datetime + rows: tuple[PopulationRow, ...] = () + never_assessed_code_line_ids: tuple[UUID, ...] = () diff --git a/src/traust_core/v1/models/attestation.py b/src/traust_core/v1/models/attestation.py index 48f8d966..57b27512 100644 --- a/src/traust_core/v1/models/attestation.py +++ b/src/traust_core/v1/models/attestation.py @@ -1,18 +1,76 @@ from __future__ import annotations from datetime import datetime +from enum import StrEnum +from typing import NewType from uuid import UUID -from traust_core.v1.models.base import Entity +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.common import Principal +from traust_core.v1.models.vocabulary import ReviewItemStatus, ReviewReason, SourceType +CommentSourceName = NewType("CommentSourceName", str) +ExternalHandle = NewType("ExternalHandle", str) -class ReviewItem(Entity): - subject_id: UUID - reason: str + +class ReviewSubjectKind(StrEnum): + FINDING = "finding" + WORK_ITEM = "work-item" + ORDER = "order" + FLEET_FIX = "fleet-fix" + RULE_DRAFT = "rule-draft" + IDENTITY_MOVE = "identity-move" + + +class AttestationDecision(StrEnum): + CONFIRM = "confirm" + REJECT = "reject" + + +# Members land with the Attestation slice (track-findings trust tiers). +class TrustTier(StrEnum): ... + + +# Shapes land with their implementation slice. +class Credential(Model): ... + + +class DirectoryStatus(Model): ... + + +class Comment(Model): + source: SourceType + author: ExternalHandle + body: str + at: datetime + url: str | None = None + + +class ReviewSubject(Model): + kind: ReviewSubjectKind + id: UUID + + +class Statement(Model): + source: SourceType + author: Principal | None = None + body: str + url: str | None = None + at: datetime + trust: TrustTier class Attestation(Entity): review_item_id: UUID - actor: str - verified: bool - attested_at: datetime + principal: Principal + decision: AttestationDecision + statement: Statement | None = None + + +class ReviewItem(Entity): + subject: ReviewSubject + reason: ReviewReason + status: ReviewItemStatus + + def resolve(self, attestation: Attestation) -> ReviewItem: + raise NotImplementedError("stub: replaces ledger resolve_handler.resolve_review_item") diff --git a/src/traust_core/v1/models/base.py b/src/traust_core/v1/models/base.py index 2d6f9cec..d29192f5 100644 --- a/src/traust_core/v1/models/base.py +++ b/src/traust_core/v1/models/base.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from uuid import UUID import pydantic @@ -14,8 +15,7 @@ class Dto(pydantic.BaseModel): class Entity(Model): - id: UUID - - -def new_id() -> UUID: - raise NotImplementedError("stub: entity identity is a UUIDv7 minted by the owning service") + # None until saved: the database generates id and both timestamps on insert. + id: UUID | None = None + created_at: datetime | None = None + updated_at: datetime | None = None diff --git a/src/traust_core/v1/models/code_facts.py b/src/traust_core/v1/models/code_facts.py index bbf8ebc0..e558eacd 100644 --- a/src/traust_core/v1/models/code_facts.py +++ b/src/traust_core/v1/models/code_facts.py @@ -1,10 +1,83 @@ from __future__ import annotations +from enum import StrEnum from uuid import UUID -from traust_core.v1.models.base import Entity +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.common import Location + + +class CodeFactKind(StrEnum): + SYMBOL = "symbol" + ROUTE_GUARD = "route-guard" + CONFIG = "config" + SANITIZER = "sanitizer" + FORK_LAG = "fork-lag" + TAINT_FLOW = "taint-flow" + CRYPTO = "crypto" + BINARY = "binary" + LANGUAGES = "languages" + IAC_PATH = "iac-path" + PRIVILEGE = "privilege" + + +# One detail model per enumerator; fields land with the enumerator's slice. +class SymbolFact(Model): ... + + +class RouteGuardFact(Model): ... + + +class ConfigFact(Model): ... + + +class SanitizerFact(Model): ... + + +class ForkLagFact(Model): ... + + +class TaintFlowFact(Model): ... + + +class CryptoFact(Model): ... + + +class BinaryFact(Model): ... + + +class LanguageStats(Model): ... + + +class IacPathFact(Model): ... + + +class PrivilegeFact(Model): ... + + +CodeFactDetail = ( + SymbolFact + | RouteGuardFact + | ConfigFact + | SanitizerFact + | ForkLagFact + | TaintFlowFact + | CryptoFact + | BinaryFact + | LanguageStats + | IacPathFact + | PrivilegeFact +) + + +class SymbolQuery(Model): ... + + +class CollectResult(Model): ... class CodeFact(Entity): revision_id: UUID - kind: str # fact kinds are pending the first enumerator implementation + kind: CodeFactKind + location: Location | None = None + detail: CodeFactDetail diff --git a/src/traust_core/v1/models/common.py b/src/traust_core/v1/models/common.py new file mode 100644 index 00000000..e0cec01e --- /dev/null +++ b/src/traust_core/v1/models/common.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Generic, NewType, TypeVar +from uuid import UUID + +from traust_core.v1.models.base import Dto, Model +from traust_core.v1.models.vocabulary import ActorKind, SourceType + +T = TypeVar("T") + +Sha256 = NewType("Sha256", str) +ArtifactName = NewType("ArtifactName", str) +RepoPath = NewType("RepoPath", str) +Cursor = NewType("Cursor", str) + + +class Role(StrEnum): + ADMIN = "admin" + RESTATE = "restate" + + +class IdentitySource(StrEnum): + LOCAL_USER = "local-user" + DATABASE = "database" + OIDC = "oidc" + + +class TimeBucket(StrEnum): + DAY = "day" + WEEK = "week" + MONTH = "month" + + +class TimeWindow(Model): + start: datetime + end: datetime + + +class ArtifactRef(Model): + id: UUID + name: ArtifactName + digest: Sha256 + + +class Provenance(Model): + run_id: UUID + artifact: ArtifactRef | None = None + source: SourceType + occurred_at: datetime + + +class Principal(Model): + id: UUID + kind: ActorKind + display: str + verified_by: IdentitySource | None = None + roles: frozenset[Role] = frozenset() + + def is_verified_human(self) -> bool: + raise NotImplementedError( + "stub: replaces ledger is_actor_verified / require_verified_human" + ) + + def has_role(self, role: Role) -> bool: + raise NotImplementedError("stub: replaces ledger require_admin / restatement authority") + + +class Location(Model): + path: RepoPath + line: int | None = None + + @classmethod + def parse(cls, raw: str) -> Location: + raise NotImplementedError("stub: replaces check_location_paths.py") + + +class PageRequest(Dto): + limit: int = 100 + cursor: Cursor | None = None + + +class Page(Dto, Generic[T]): + items: tuple[T, ...] + next: Cursor | None = None diff --git a/src/traust_core/v1/models/compliance.py b/src/traust_core/v1/models/compliance.py index 1d970d6f..4d6352d3 100644 --- a/src/traust_core/v1/models/compliance.py +++ b/src/traust_core/v1/models/compliance.py @@ -1,18 +1,130 @@ from __future__ import annotations +from datetime import datetime +from typing import NewType from uuid import UUID -from traust_core.v1 import contracts -from traust_core.v1.models.base import Entity +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.common import ArtifactRef, Principal, Sha256 +from traust_core.v1.models.findings import SkillInput +from traust_core.v1.models.vocabulary import ( + AdrDeclaredStatus, + ComplianceAssertionOperator, + ComplianceEvidenceKind, + ComplianceFramework, + ComplianceTargetKind, + ComplianceTargetResolvesVia, + ComplianceVerdict, + ComplianceVerdictSource, + DocVarianceDisposition, + DocVarianceKind, + ScopeBindingMode, +) -ComplianceVerdict = contracts.enum("compliance-verdict") +EvidenceId = NewType("EvidenceId", str) +TargetRef = NewType("TargetRef", str) -class ComplianceScope(Entity): ... +# Shapes land with their implementation slice. +class ControlRef(Model): ... -class ComplianceResult(Entity): +class AssertionResult(Model): ... + + +class CompliancePosture(Model): ... + + +class InventorySnapshot(Model): ... + + +class EvaluationRun(Model): ... + + +class ScopeFilter(Model): ... + + +class ComplianceFilter(Model): ... + + +class ComplianceDimension(Model): ... + + +class ComplianceRollup(Model): ... + + +class DocVarianceFilter(Model): ... + + +class ComplianceInput(SkillInput): ... + + +class ComplianceTarget(Model): + kind: ComplianceTargetKind + ref: TargetRef + resolves_via: ComplianceTargetResolvesVia + + +class ComplianceEvidence(Model): + id: EvidenceId + kind: ComplianceEvidenceKind + digest: Sha256 + artifact: ArtifactRef + + @classmethod + def identify(cls, kind: ComplianceEvidenceKind, payload: bytes) -> ComplianceEvidence: + raise NotImplementedError("stub: replaces compliance_assert.evidence_id") + + +class Assertion(Model): + control: ControlRef + selector: str + operator: ComplianceAssertionOperator + expected: object + + def evaluate(self, evidence: ComplianceEvidence) -> AssertionResult: + raise NotImplementedError("stub: replaces compliance_assert.eval_assertion") + + +class ComplianceScope(Entity): + product_id: UUID + frameworks: tuple[ComplianceFramework, ...] = () + targets: tuple[ComplianceTarget, ...] = () + binding: ScopeBindingMode + declared_by: Principal + + def covers(self, target: ComplianceTarget) -> bool: + raise NotImplementedError("stub: replaces compliance/scope.resolve") + + +class ControlResult(Model): + control: ControlRef + verdict: ComplianceVerdict + source: ComplianceVerdictSource + evidence: tuple[EvidenceId, ...] = () + + +class ComplianceAssessment(Entity): scope_id: UUID - framework: str - control_id: str - verdict: ComplianceVerdict # type: ignore[valid-type] + framework: ComplianceFramework + assessed_at: datetime + results: tuple[ControlResult, ...] = () + artifact: ArtifactRef + + def posture(self) -> CompliancePosture: + raise NotImplementedError("stub: replaces compliance/dashboard.latest_per_target_framework") + + +class Adr(Entity): + product_id: UUID + number: int + title: str + status: AdrDeclaredStatus + artifact: ArtifactRef + + +class DocVariance(Entity): + product_id: UUID + kind: DocVarianceKind + disposition: DocVarianceDisposition + artifact: ArtifactRef diff --git a/src/traust_core/v1/models/detection_quality.py b/src/traust_core/v1/models/detection_quality.py index bd3634a9..ecc7abf5 100644 --- a/src/traust_core/v1/models/detection_quality.py +++ b/src/traust_core/v1/models/detection_quality.py @@ -1,17 +1,107 @@ from __future__ import annotations +from datetime import datetime from uuid import UUID -from traust_core.v1.models.base import Entity +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.common import ArtifactRef +from traust_core.v1.models.exposure import AdvisoryIdentifier +from traust_core.v1.models.vocabulary import ( + BenchmarkTargetEmbargo, + BenchmarkTargetEvidenceKind, + BenchmarkTargetInjectionClass, + BenchmarkTargetProvenance, + ComplianceFramework, +) -class BenchmarkSuite(Entity): - version: str +# Shapes land with their implementation slice. +class Scorecard(Model): ... + + +class RuleRef(Model): ... + + +class RuleDraftSource(Model): ... + + +class RuleDraftStatus(Model): ... + + +class Language(Model): ... + + +class CalibrationDelta(Model): ... + + +class MissReason(Model): ... + + +class Canary(Model): ... + + +class InjectionScreen(Model): ... + + +class BenchmarkFilter(Model): ... + + +class MissFilter(Model): ... + + +class BenchmarkTarget(Entity): + advisory: AdvisoryIdentifier + repository_id: UUID + fix_revision_id: UUID + provenance: BenchmarkTargetProvenance + embargo: BenchmarkTargetEmbargo + evidence: BenchmarkTargetEvidenceKind + injection_class: BenchmarkTargetInjectionClass | None = None + + +class BenchmarkMatch(Model): + target_id: UUID + matched: bool + finding_id: UUID | None = None + injection_score: float | None = None class BenchmarkRun(Entity): - suite_id: UUID + rule_pack_id: UUID + started_at: datetime + matches: tuple[BenchmarkMatch, ...] = () + + def scorecard(self) -> Scorecard: + raise NotImplementedError("stub: replaces match_benchmark.score, sweep/benchmark.cmd_score") class RulePack(Entity): name: str + version: str + rules: tuple[RuleRef, ...] = () + + +class RuleDraft(Entity): + source: RuleDraftSource + language: Language + artifact: ArtifactRef + status: RuleDraftStatus + + +class Calibration(Model): + subject_id: UUID + precision: float + reachability: float + delta: CalibrationDelta + + +class CriticalMiss(Entity): + advisory: AdvisoryIdentifier + repository_id: UUID + reason: MissReason + + +class ComplianceCalibration(Model): + framework: ComplianceFramework + precision: float + efficacy: float diff --git a/src/traust_core/v1/models/exposure.py b/src/traust_core/v1/models/exposure.py index dea839c0..50642847 100644 --- a/src/traust_core/v1/models/exposure.py +++ b/src/traust_core/v1/models/exposure.py @@ -1,19 +1,129 @@ from __future__ import annotations +from datetime import datetime +from enum import StrEnum +from typing import NewType from uuid import UUID -from traust_core.v1 import contracts -from traust_core.v1.models.base import Entity +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.common import ArtifactRef +from traust_core.v1.models.findings import SkillInput +from traust_core.v1.models.vocabulary import ( + DependencyEvidenceLevel, + Ecosystem, + ImpactClassification, + ImpactTier, + Severity, +) -ImpactClassification = contracts.enum("impact-classification") +AdvisoryIdentifier = NewType("AdvisoryIdentifier", str) +Version = NewType("Version", str) +QualifiedSymbol = NewType("QualifiedSymbol", str) +FeedName = NewType("FeedName", str) + + +class VexStatus(StrEnum): + NOT_AFFECTED = "not_affected" + AFFECTED = "affected" + FIXED = "fixed" + UNDER_INVESTIGATION = "under_investigation" + + +# Shapes land with their implementation slice. +class SymbolSource(Model): ... + + +class ImpactEvidence(Model): ... + + +class DependencyIngest(Model): ... + + +class DependencyProvenance(Model): ... + + +class ProvenanceCheck(Model): ... + + +class AffectedDependency(Model): ... + + +class ImpactFilter(Model): ... + + +class ExposureFilter(Model): ... + + +class ExposureDimension(Model): ... + + +class ExposureRollup(Model): ... + + +class ImpactInput(SkillInput): ... + + +class FeedRun(Entity): + feed: FeedName + error: str | None = None + + +class Package(Model): + ecosystem: Ecosystem + name: str + + +class AffectedRange(Model): + package: Package + introduced: Version | None = None + fixed: Version | None = None + + def contains(self, version: Version) -> bool: + raise NotImplementedError("stub: replaces impact/analyzer.version_in_range") class Advisory(Entity): - identifier: str - aliases: tuple[str, ...] = () + identifier: AdvisoryIdentifier + aliases: tuple[AdvisoryIdentifier, ...] = () + summary: str + severity: Severity | None = None + affected: tuple[AffectedRange, ...] = () + published_at: datetime | None = None + kev: bool = False + epss: float | None = None + + def affects(self, package: Package, version: Version) -> bool: + raise NotImplementedError("stub: replaces dependency-watch/fleet_sweep.fixed_range") + + def fixed_in(self, package: Package) -> Version | None: + raise NotImplementedError("stub: replaces adapters/osv.fixed_version") + + +class AdvisorySymbols(Model): + advisory_id: UUID + symbols: tuple[QualifiedSymbol, ...] = () + entry_points: tuple[QualifiedSymbol, ...] = () + source: SymbolSource + + +class Dependency(Entity): + revision_id: UUID + package: Package + version: Version + direct: bool + evidence: DependencyEvidenceLevel class ImpactAnalysis(Entity): advisory_id: UUID - repository_id: UUID - classification: ImpactClassification # type: ignore[valid-type] + revision_id: UUID + classification: ImpactClassification + tier: ImpactTier + evidence: tuple[ImpactEvidence, ...] = () + artifact: ArtifactRef + + +class VexStatement(Model): + advisory: AdvisoryIdentifier + product_id: UUID + status: VexStatus diff --git a/src/traust_core/v1/models/findings.py b/src/traust_core/v1/models/findings.py index f1a9766a..1e5f21db 100644 --- a/src/traust_core/v1/models/findings.py +++ b/src/traust_core/v1/models/findings.py @@ -1,39 +1,217 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Mapping, Sequence +from datetime import datetime +from enum import StrEnum +from typing import NewType from uuid import UUID from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.common import ArtifactRef, Location, Principal, Provenance from traust_core.v1.models.integrity import Fingerprint +from traust_core.v1.models.vocabulary import ( + DispositionAssurance, + DispositionEmbargo, + DispositionResolution, + EvidenceGrade, + FindingOrigin, + Severity, + Validity, +) + +Label = NewType("Label", str) +Cwe = NewType("Cwe", str) +Category = NewType("Category", str) +Confidence = NewType("Confidence", float) +Rationale = NewType("Rationale", str) +StatementKey = NewType("StatementKey", str) +EvidenceRef = NewType("EvidenceRef", str) + + +class CandidateFormat(StrEnum): + SARIF = "sarif" + DEPENDABOT = "dependabot" + GOVULNCHECK = "govulncheck" + + +class FindingDimension(StrEnum): + SEVERITY = "severity" + CWE = "cwe" + CATEGORY = "category" + LABEL = "label" + ORIGIN = "origin" + VALIDITY = "validity" + RESOLUTION = "resolution" + REPOSITORY = "repository" + + +# Shapes land with their implementation slice. +class WeaknessLabels(Model): ... + + +class SeverityOverride(Model): ... + + +class ReviewContext(Model): ... + + +class PrecedentReason(Model): ... + + +class IdentityMove(Model): ... + + +class IntakeResult(Model): ... + + +class RecordDecision(Model): ... + + +class TriageRecorded(Model): ... + + +class ReviewCase(Model): ... + + +class ReviewFilter(Model): ... + + +class FindingFilter(Model): ... + + +class FindingRollup(Model): ... + + +class RatingInputs(Model): ... + + +class RiskRating(Model): ... class FindingCandidate(Model): - label: str + label: Label repository_id: UUID - cwes: tuple[str, ...] = () + title: str + cwes: tuple[Cwe, ...] = () + category: Category | None = None + locations: tuple[Location, ...] = () + reported_severity: Severity + confidence: Confidence + origin: FindingOrigin + + @classmethod + def parse_many(cls, raw: bytes, format: CandidateFormat) -> list[FindingCandidate]: + raise NotImplementedError("stub: replaces triage/normalize_input.py") class Finding(Entity): repository_id: UUID match_key: Fingerprint - cwes: tuple[str, ...] = () + title: str + cwes: tuple[Cwe, ...] = () + category: Category | None = None + labels: WeaknessLabels + origin: FindingOrigin + first_seen_at: datetime class Occurrence(Entity): finding_id: UUID assessment_id: UUID - label: str + label: Label + locations: tuple[Location, ...] = () + reported_severity: Severity + confidence: Confidence + artifact: ArtifactRef -# Decision and Disposition fields are pending Q3 and the classifier plan's vocabulary. class Decision(Entity): finding_id: UUID + code_line_id: UUID | None = None + validity: Validity | None = None + resolution: DispositionResolution | None = None + severity_override: SeverityOverride | None = None + evidence_grade: EvidenceGrade + evidence: tuple[EvidenceRef, ...] = () + embargo: DispositionEmbargo | None = None + actor: Principal + provenance: Provenance + rationale: Rationale + review: ReviewContext | None = None + restates_id: UUID | None = None + + def statement_key(self) -> StatementKey: + raise NotImplementedError("stub: replaces ledger compute_event_id (classifier steps 9/12)") class Disposition(Model): finding_id: UUID + code_line_id: UUID + validity: Validity + resolution: DispositionResolution + assurance: DispositionAssurance + conflict: bool = False + fp_overridden: bool = False + fp_reassertion_blocked: bool = False + awaiting_signoff: bool = False + last_decision_at: datetime | None = None + + @classmethod + def fold(cls, finding_id: UUID, decisions: Sequence[Decision]) -> Disposition: + raise NotImplementedError("stub: replaces ledger derive_disposition, corpus/disposition.py") + + def admit(self, decision: Decision) -> Disposition: + raise NotImplementedError("stub: replaces ledger _internal/gates.py") + + def is_open(self) -> bool: + raise NotImplementedError("stub: replaces build_trends.state_of") + + +class Precedent(Model): + decision: Decision + similarity: float + reason: PrecedentReason + + +class FindingView(Model): + finding: Finding + occurrence: Occurrence | None = None + disposition: Disposition | None = None + effective_severity: Severity + sla_due: datetime | None = None + + +class SubjectRef(Model): ... + + +class FindingContext(Model): + baseline: tuple[FindingView, ...] = () + precedents: tuple[Precedent, ...] = () + + +class SkillInput(Model): + subject: SubjectRef + findings: FindingContext + prepared_at: datetime + + +class TriageInput(SkillInput): ... + + +class FindingSeriesPoint(Model): + bucket: datetime + open_by_severity: Mapping[Severity, int] + new: int + resolved: int + accepted: int + hardening: int + false_positives: int + regressions: int + mttr_days: float | None = None + risk_index: float +# Legacy triage row; becomes a Decision in the Findings slice. class TriageVerdict(Model): binding_id: str artifact_digest: str diff --git a/src/traust_core/v1/models/insight.py b/src/traust_core/v1/models/insight.py new file mode 100644 index 00000000..bc8d5b49 --- /dev/null +++ b/src/traust_core/v1/models/insight.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from traust_core.v1.models.base import Model +from traust_core.v1.models.common import TimeWindow +from traust_core.v1.models.portfolio import BusinessUnit, TeamName + + +class ViewName(StrEnum): + FINDINGS_ROLLUP = "findings-rollup" + FINDINGS_TREND = "findings-trend" + COVERAGE = "coverage" + LOC = "loc" + ATTACK_COVERAGE = "attack-coverage" + THREAT_REGISTER = "threat-register" + EXPOSURE = "exposure" + VALIDATION_FUZZ = "validation-fuzz" + PQC = "pqc" + COMPLIANCE = "compliance" + PROGRAM = "program" + EXECUTIVE_SUMMARY = "executive-summary" + REVIEW_CARD = "review-card" + TRIAGE = "triage" + SARIF = "sarif" + OSCAL = "oscal" + + +# Each view's model is a contracts JSON schema (I4); shapes land with the view's slice. +class ViewModel(Model): ... + + +class ViewFilter(Model): + product_ids: tuple[UUID, ...] = () + business_units: tuple[BusinessUnit, ...] = () + teams: tuple[TeamName, ...] = () + as_of: datetime | None = None + window: TimeWindow | None = None diff --git a/src/traust_core/v1/models/ledger.py b/src/traust_core/v1/models/ledger.py new file mode 100644 index 00000000..f9cffe1a --- /dev/null +++ b/src/traust_core/v1/models/ledger.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from uuid import UUID + +from traust_core.v1.models.attestation import Attestation +from traust_core.v1.models.base import Model +from traust_core.v1.models.common import Principal +from traust_core.v1.models.findings import Decision + + +# Members land with the Ledger slice (from traust-ledger _internal/kinds.py). +class LedgerEventKind(StrEnum): ... + + +# Shapes land with their implementation slice. +class AppendReceipt(Model): ... + + +class VerifyResult(Model): ... + + +class LedgerEvent(Model): + event_id: UUID + stream_id: UUID + kind: LedgerEventKind + actor: Principal + occurred_at: datetime + recorded_at: datetime + payload: bytes + + @classmethod + def from_decision(cls, decision: Decision, stream_id: UUID) -> LedgerEvent: + raise NotImplementedError("stub: replaces ledger events/builders.py, lib/event_time.py") + + @classmethod + def from_attestation(cls, attestation: Attestation, stream_id: UUID) -> LedgerEvent: + raise NotImplementedError("stub: replaces ledger events/builders.build_human_event") + + +class Checkpoint(Model): + stream_id: UUID + owner_id: UUID + merkle_root: str + size: int + epoch: int + signature: str diff --git a/src/traust_core/v1/models/onboarding.py b/src/traust_core/v1/models/onboarding.py new file mode 100644 index 00000000..faca097a --- /dev/null +++ b/src/traust_core/v1/models/onboarding.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from uuid import UUID + +from traust_core.v1.models.assessment import AssessmentKind +from traust_core.v1.models.base import Model +from traust_core.v1.models.operations import AssetRequest +from traust_core.v1.models.portfolio import ( + Designation, + OrgName, + Ownership, + ProductSlug, + RefName, + Segment, +) +from traust_core.v1.models.values import HttpsRepoUrl + + +class Gap(Model): ... + + +class RepositorySpec(Model): + url: HttpsRepoUrl + refs: tuple[RefName, ...] = () + designation: Designation | None = None + ownership: Ownership | None = None + + +class OnboardingRequest(Model): + product: ProductSlug + segment: Segment | None = None + repositories: tuple[RepositorySpec, ...] = () + org: OrgName | None = None + baseline: tuple[AssessmentKind, ...] = () + + +class OnboardingPlan(Model): + new: tuple[RepositorySpec, ...] = () + existing_repository_ids: tuple[UUID, ...] = () + gaps: tuple[Gap, ...] = () + orders: tuple[AssetRequest, ...] = () + + +class OnboardingResult(Model): + product_id: UUID + repository_ids: tuple[UUID, ...] = () + order_ids: tuple[UUID, ...] = () diff --git a/src/traust_core/v1/models/operations.py b/src/traust_core/v1/models/operations.py index 8574fea8..55d8d8dc 100644 --- a/src/traust_core/v1/models/operations.py +++ b/src/traust_core/v1/models/operations.py @@ -2,12 +2,23 @@ from collections.abc import Mapping from datetime import datetime +from decimal import Decimal from enum import StrEnum +from typing import NewType from uuid import UUID from pydantic import Field from traust_core.v1.models.base import Dto, Entity, Model +from traust_core.v1.models.common import Principal +from traust_core.v1.models.vocabulary import BudgetEnforcement + +AssetName = NewType("AssetName", str) +WorkerId = NewType("WorkerId", str) +LaneName = NewType("LaneName", str) +ProviderName = NewType("ProviderName", str) +ModelName = NewType("ModelName", str) +SkillName = NewType("SkillName", str) class Outcome(StrEnum): @@ -43,7 +54,7 @@ def failed(cls, reason: str) -> JobResult: class AssetRequest(Dto): - asset: str + asset: AssetName run_id: str partition: str | None = None params: Mapping[str, str] = Field(default_factory=dict) @@ -51,28 +62,97 @@ class AssetRequest(Dto): class Refusal(Dto): subject: str - lane: str + lane: LaneName reason: str class Route(Dto): - lane: str | None + lane: LaneName | None priority: int reason: str +# Members land with the Operations slice (traust-engine run loop). +class OrderState(StrEnum): ... + + +class OrderEventKind(StrEnum): ... + + +# Shapes land with their implementation slice. +class OrderProgress(Model): ... + + +class SchedulePlan(Model): ... + + +class OrderFilter(Model): ... + + +class ThroughputPoint(Model): ... + + +class SpendSource(Model): ... + + +class SpendAttribution(Model): ... + + +class BudgetVerdict(Model): ... + + +class SpendFilter(Model): ... + + +class SpendDimension(Model): ... + + +class SpendRollup(Model): ... + + +class StaleItem(Model): ... + + class Order(Entity): request: AssetRequest - state: str # order lifecycle states are pending the engine run loop + state: OrderState + requested_by: Principal + depends_on_ids: tuple[UUID, ...] = () + progress: OrderProgress | None = None + + def transition(self, event: OrderEventKind, at: datetime) -> Order: + raise NotImplementedError("stub: replaces cron/subprocess orchestration state") class Lease(Model): order_id: UUID - worker: str + worker: WorkerId expires_at: datetime + def expired(self, now: datetime) -> bool: + raise NotImplementedError("stub: order lease for the traust-engine run loop") -class SpendEvent(Model): + +class OrderEvent(Entity): order_id: UUID - provider: str - cost_usd: float | None = None + kind: OrderEventKind + detail: str | None = None + + +class SpendEvent(Entity): + order_id: UUID | None = None + provider: ProviderName + model: ModelName | None = None + skill: SkillName | None = None + tokens: int | None = None + cost_usd: Decimal + at: datetime + + +class Budget(Model): + lane: LaneName + monthly_usd: Decimal + enforcement: BudgetEnforcement + + def verdict(self, spent: Decimal, estimate: Decimal) -> BudgetVerdict: + raise NotImplementedError("stub: replaces budget_shadow.shadow_verdict") diff --git a/src/traust_core/v1/models/portfolio.py b/src/traust_core/v1/models/portfolio.py index 0f6d1b61..02893f06 100644 --- a/src/traust_core/v1/models/portfolio.py +++ b/src/traust_core/v1/models/portfolio.py @@ -1,13 +1,22 @@ from __future__ import annotations +from datetime import datetime, timedelta from enum import StrEnum +from typing import NewType from uuid import UUID -from traust_core.v1 import contracts -from traust_core.v1.models.base import Entity -from traust_core.v1.models.values import HttpsRepoUrl +from traust_core.v1.models.assessment import Freshness +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.values import GitSha, HttpsRepoUrl +from traust_core.v1.models.vocabulary import OwnershipClass, RefKind -RefKind = contracts.enum("ref-kind") +ProductSlug = NewType("ProductSlug", str) +RefName = NewType("RefName", str) +VersionLabel = NewType("VersionLabel", str) +TeamName = NewType("TeamName", str) +BusinessUnit = NewType("BusinessUnit", str) +OrgName = NewType("OrgName", str) +ImageRef = NewType("ImageRef", str) class Designation(StrEnum): @@ -29,17 +38,126 @@ class RiskTier(StrEnum): P3 = "P3" +class Visibility(StrEnum): + PUBLIC = "public" + PRIVATE = "private" + + +class ForgeHost(StrEnum): + GITHUB = "github" + GITLAB = "gitlab" + + +# Shapes land with their implementation slice. +class Segment(Model): ... + + +class ProductLifecycle(Model): ... + + +class Contact(Model): ... + + +class TrackerTarget(Model): ... + + +class OwnershipSource(Model): ... + + +class ResourceType(Model): ... + + +class ComponentSpec(Model): ... + + +class ReleaseChange(Model): ... + + +class SyncReport(Model): ... + + +class RepositoryFilter(Model): ... + + +class ComponentFilter(Model): ... + + +class Liveness(Model): + archived: bool + pushed_at: datetime | None = None + observed_at: datetime + + +class Ownership(Model): + team: TeamName + business_unit: BusinessUnit + ownership: OwnershipClass + manager: str | None = None + tracker: TrackerTarget | None = None + source: OwnershipSource + + class Product(Entity): - slug: str + slug: ProductSlug + segment: Segment | None = None + lifecycle: ProductLifecycle | None = None + contacts: tuple[Contact, ...] = () class Repository(Entity): url: HttpsRepoUrl + visibility: Visibility | None = None + has_public_upstream: bool = False designation: Designation | None = None + liveness: Liveness | None = None + + def exposure_class(self) -> ExposureClass: + raise NotImplementedError("stub: replaces build_rescan_worklist.exposure_class") + + def is_dormant(self, now: datetime, after: timedelta) -> bool: + raise NotImplementedError("stub: replaces check_repo_liveness.classify") + + def risk_tier(self, open_critical_high: int, now: datetime) -> RiskTier: + raise NotImplementedError("stub: replaces build_rescan_worklist.risk_tier") -# Repository -> CodeLine follows the Q4 proposal; fields beyond identity are pending. class CodeLine(Entity): repository_id: UUID - ref: str - kind: RefKind # type: ignore[valid-type] + ref: RefName + kind: RefKind + + +class Revision(Entity): + repository_id: UUID + sha: GitSha + + +class Component(Entity): + product_id: UUID + code_line_id: UUID + sub_service: str | None = None + resource_type: ResourceType | None = None + ownership: Ownership | None = None + + +class Release(Entity): + component_id: UUID + version: VersionLabel + category: str | None = None + images: tuple[ImageRef, ...] = () + + def diff(self, previous: Release) -> ReleaseChange: + raise NotImplementedError("stub: replaces build_release_events.diff_container") + + +class CrownJewelTier(Model): ... + + +class RepoState(Model): + repository_id: UUID + exposure: ExposureClass + risk_tier: RiskTier + crown_jewel: CrownJewelTier | None = None + freshness: Freshness | None = None + open_critical_high: int + liveness: Liveness | None = None diff --git a/src/traust_core/v1/models/pqc.py b/src/traust_core/v1/models/pqc.py new file mode 100644 index 00000000..6f1c2427 --- /dev/null +++ b/src/traust_core/v1/models/pqc.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from uuid import UUID + +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.code_facts import CryptoFact +from traust_core.v1.models.exposure import Package +from traust_core.v1.models.findings import SkillInput +from traust_core.v1.models.vocabulary import ( + PqcBlastRadius, + PqcClassification, + PqcReadinessBucket, + PqcRemediationCategory, +) + + +class PqcFilter(Model): ... + + +class PqcDimension(Model): ... + + +class PqcRollup(Model): ... + + +class PqcReadinessInput(SkillInput): ... + + +class PqcBlocker(Model): + package: Package + category: PqcRemediationCategory + vendor: str | None = None + + +class PqcReadiness(Entity): + assessment_id: UUID + bucket: PqcReadinessBucket + classification: PqcClassification + blast_radius: PqcBlastRadius + facts: tuple[CryptoFact, ...] = () + blockers: tuple[PqcBlocker, ...] = () + + def is_blocked(self) -> bool: + raise NotImplementedError("stub: replaces build_pqc_blockers.build_findings_doc") diff --git a/src/traust_core/v1/models/remediation.py b/src/traust_core/v1/models/remediation.py index 1db830e5..fa2085a2 100644 --- a/src/traust_core/v1/models/remediation.py +++ b/src/traust_core/v1/models/remediation.py @@ -1,13 +1,116 @@ from __future__ import annotations +from enum import StrEnum from uuid import UUID -from traust_core.v1 import contracts -from traust_core.v1.models.base import Entity +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.common import ArtifactRef +from traust_core.v1.models.findings import SkillInput +from traust_core.v1.models.portfolio import OrgName +from traust_core.v1.models.values import HttpsRepoUrl +from traust_core.v1.models.vocabulary import ( + CrossRepoPropagation, + FixStrategy, + FleetFixMatcherKind, + ForkHost, + ForkVisibility, + PatchEvidenceKind, + RemediationEffort, + RemediationStatus, + VerificationVerdict, +) -RemediationStatus = contracts.enum("remediation-status") + +class EvidenceStatus(StrEnum): + ATTEMPTED = "attempted" + REFUSED = "refused" + NOT_ATTEMPTED = "not_attempted" + + +# Shapes land with their implementation slice. +class ExternalRef(Model): ... + + +class FleetFixSpec(Model): ... + + +class FleetTarget(Model): ... + + +class PatchResult(Model): ... + + +class PropagationEvidence(Model): ... + + +class WorkItemFilter(Model): ... + + +class VerificationFilter(Model): ... + + +class RemediationInput(SkillInput): ... + + +class VerificationInput(SkillInput): ... + + +class FleetFixMatcher(Model): + kind: FleetFixMatcherKind + + +class EvidenceItem(Model): + kind: PatchEvidenceKind + status: EvidenceStatus + artifact: ArtifactRef | None = None + reason: str | None = None + + @classmethod + def not_attempted(cls, kind: PatchEvidenceKind, reason: str) -> EvidenceItem: + raise NotImplementedError("stub: replaces the 3 duplicated _not_attempted helpers") + + +class Fork(Model): + host: ForkHost + owner: OrgName + url: HttpsRepoUrl + visibility: ForkVisibility class WorkItem(Entity): finding_ids: tuple[UUID, ...] - status: RemediationStatus # type: ignore[valid-type] + component_id: UUID + status: RemediationStatus + strategy: FixStrategy | None = None + fork: Fork | None = None + effort: RemediationEffort | None = None + tracker: ExternalRef | None = None + evidence: tuple[EvidenceItem, ...] = () + + def advance(self, status: RemediationStatus) -> WorkItem: + raise NotImplementedError("stub: replaces update_remediation_progress.py") + + def is_done(self) -> bool: + raise NotImplementedError("stub: replaces next_pending_remediation.is_done") + + +class FleetFix(Entity): + matcher: FleetFixMatcher + spec: FleetFixSpec + target_ids: tuple[UUID, ...] = () + approval_id: UUID | None = None + + +class Verification(Entity): + work_item_id: UUID + revision_id: UUID + verdict: VerificationVerdict + regression_ids: tuple[UUID, ...] = () + artifact: ArtifactRef + + +class Propagation(Model): + work_item_id: UUID + component_id: UUID + status: CrossRepoPropagation + evidence: tuple[PropagationEvidence, ...] = () diff --git a/src/traust_core/v1/models/validation.py b/src/traust_core/v1/models/validation.py index bf2ec1d2..c4ad117d 100644 --- a/src/traust_core/v1/models/validation.py +++ b/src/traust_core/v1/models/validation.py @@ -1,17 +1,113 @@ from __future__ import annotations +from datetime import datetime from uuid import UUID -from traust_core.v1 import contracts -from traust_core.v1.models.base import Entity +from traust_core.v1.models.base import Entity, Model +from traust_core.v1.models.findings import EvidenceRef, SkillInput +from traust_core.v1.models.vocabulary import ( + ValidationAdapterKind, + ValidationApprovalMode, + ValidationTechnique, + ValidationVerdict, +) -ValidationVerdict = contracts.enum("validation-verdict") + +# Shapes land with their implementation slice. +class ValidationAction(Model): ... + + +class ClusterScope(Model): ... + + +class OffLimit(Model): ... + + +class BrowserTarget(Model): ... + + +class ValidationTarget(Model): ... + + +class PlanStep(Model): ... + + +class RefusalReason(Model): ... + + +class SoundnessResult(Model): ... + + +class ResourceState(Model): ... + + +class ClusterDiff(Model): ... + + +class FindingChain(Model): ... + + +class Authorization(Model): ... + + +class TargetObservation(Model): ... + + +class TargetAttestation(Model): ... + + +class ValidationRun(Model): ... + + +class ValidationFilter(Model): ... + + +class ValidationDimension(Model): ... + + +class ValidationRollup(Model): ... + + +class ValidationInput(SkillInput): ... + + +class ValidationScope(Model): + actions: frozenset[ValidationAction] = frozenset() + clusters: tuple[ClusterScope, ...] = () + off_limits: tuple[OffLimit, ...] = () + browser: tuple[BrowserTarget, ...] = () + + def permits(self, action: ValidationAction, target: ValidationTarget) -> bool: + raise NotImplementedError("stub: replaces validation/scope.py x2, browser scope.py") class ValidationPlan(Entity): finding_id: UUID + target: ValidationTarget + technique: ValidationTechnique + adapter: ValidationAdapterKind + approval_mode: ValidationApprovalMode + steps: tuple[PlanStep, ...] = () + refusal: RefusalReason | None = None + + def requires_approval(self) -> bool: + raise NotImplementedError("stub: replaces validate-findings approval mode") class ValidationAttempt(Entity): plan_id: UUID - verdict: ValidationVerdict # type: ignore[valid-type] + verdict: ValidationVerdict + evidence: tuple[EvidenceRef, ...] = () + started_at: datetime + finished_at: datetime + + def soundness(self) -> SoundnessResult: + raise NotImplementedError("stub: replaces soundness.py x2, lint_refutation_soundness.py") + + +class ClusterSnapshot(Model): + taken_at: datetime + resources: tuple[ResourceState, ...] = () + + def diff(self, other: ClusterSnapshot) -> ClusterDiff: + raise NotImplementedError("stub: replaces cluster_state_diff.diff") diff --git a/src/traust_core/v1/models/values.py b/src/traust_core/v1/models/values.py index 2399d7ee..0a8342f9 100644 --- a/src/traust_core/v1/models/values.py +++ b/src/traust_core/v1/models/values.py @@ -7,6 +7,8 @@ from traust_core.v1.errors import ValidationError +# NEED(validation): the str NewTypes elsewhere (ProductSlug, RefName, Cwe, ...) only label meaning; +# each that needs a rule (git URI, CWE id, semver) becomes a value object here with parse(). _SHA = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}") _CVE = re.compile(r"CVE-\d{4}-\d{4,}") _PATH_SEGMENT = re.compile(r"[A-Za-z0-9._-]+") diff --git a/src/traust_core/v1/models/vocabulary.py b/src/traust_core/v1/models/vocabulary.py new file mode 100644 index 00000000..55a196e5 --- /dev/null +++ b/src/traust_core/v1/models/vocabulary.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from traust_core.v1 import contracts + +# Closed vocabularies owned by traust-contracts; type checkers see StrEnum. +if TYPE_CHECKING: + from enum import StrEnum + + ActorKind = StrEnum + AdrDeclaredStatus = StrEnum + AuditProfile = StrEnum + BenchmarkTargetEmbargo = StrEnum + BenchmarkTargetEvidenceKind = StrEnum + BenchmarkTargetInjectionClass = StrEnum + BenchmarkTargetProvenance = StrEnum + BudgetEnforcement = StrEnum + ChainSeverity = StrEnum + ComplianceAssertionOperator = StrEnum + ComplianceEvidenceKind = StrEnum + ComplianceFramework = StrEnum + ComplianceTargetKind = StrEnum + ComplianceTargetResolvesVia = StrEnum + ComplianceVerdict = StrEnum + ComplianceVerdictSource = StrEnum + CrossRepoPropagation = StrEnum + DependencyEvidenceLevel = StrEnum + DispositionAssurance = StrEnum + DispositionEmbargo = StrEnum + DispositionResolution = StrEnum + DocVarianceDisposition = StrEnum + DocVarianceKind = StrEnum + Ecosystem = StrEnum + EvidenceGrade = StrEnum + FindingOrigin = StrEnum + FixStrategy = StrEnum + FleetFixMatcherKind = StrEnum + ForkHost = StrEnum + ForkVisibility = StrEnum + ImpactClassification = StrEnum + ImpactTier = StrEnum + IsolationPosture = StrEnum + OwnershipClass = StrEnum + PatchEvidenceKind = StrEnum + PeachBoundaryType = StrEnum + PqcBlastRadius = StrEnum + PqcClassification = StrEnum + PqcReadinessBucket = StrEnum + PqcRemediationCategory = StrEnum + RefKind = StrEnum + RemediationEffort = StrEnum + RemediationStatus = StrEnum + RestatementReason = StrEnum + ReviewItemStatus = StrEnum + ReviewReason = StrEnum + RiskRatingBand = StrEnum + ScopeBindingMode = StrEnum + Severity = StrEnum + SourceType = StrEnum + TenancyProfile = StrEnum + ThreatActor = StrEnum + ThreatImpact = StrEnum + ThreatLikelihood = StrEnum + ThreatStatus = StrEnum + ValidationAdapterKind = StrEnum + ValidationApprovalMode = StrEnum + ValidationTechnique = StrEnum + ValidationVerdict = StrEnum + Validity = StrEnum + Verdict = StrEnum + VerificationVerdict = StrEnum +else: + ActorKind = contracts.enum("actor-kind") + AdrDeclaredStatus = contracts.enum("adr-declared-status") + AuditProfile = contracts.enum("audit-profile") + BenchmarkTargetEmbargo = contracts.enum("benchmark-target-embargo") + BenchmarkTargetEvidenceKind = contracts.enum("benchmark-target-evidence-kind") + BenchmarkTargetInjectionClass = contracts.enum("benchmark-target-injection-class") + BenchmarkTargetProvenance = contracts.enum("benchmark-target-provenance") + BudgetEnforcement = contracts.enum("budget-enforcement") + ChainSeverity = contracts.enum("chain-severity") + ComplianceAssertionOperator = contracts.enum("compliance-assertion-operator") + ComplianceEvidenceKind = contracts.enum("compliance-evidence-kind") + ComplianceFramework = contracts.enum("compliance-framework") + ComplianceTargetKind = contracts.enum("compliance-target-kind") + ComplianceTargetResolvesVia = contracts.enum("compliance-target-resolves-via") + ComplianceVerdict = contracts.enum("compliance-verdict") + ComplianceVerdictSource = contracts.enum("compliance-verdict-source") + CrossRepoPropagation = contracts.enum("cross-repo-propagation") + DependencyEvidenceLevel = contracts.enum("dependency-evidence-level") + DispositionAssurance = contracts.enum("disposition-assurance") + DispositionEmbargo = contracts.enum("disposition-embargo") + DispositionResolution = contracts.enum("disposition-resolution") + DocVarianceDisposition = contracts.enum("doc-variance-disposition") + DocVarianceKind = contracts.enum("doc-variance-kind") + Ecosystem = contracts.enum("ecosystem") + EvidenceGrade = contracts.enum("evidence-grade") + FindingOrigin = contracts.enum("finding-origin") + FixStrategy = contracts.enum("fix-strategy") + FleetFixMatcherKind = contracts.enum("fleet-fix-matcher-kind") + ForkHost = contracts.enum("fork-host") + ForkVisibility = contracts.enum("fork-visibility") + ImpactClassification = contracts.enum("impact-classification") + ImpactTier = contracts.enum("impact-tier") + IsolationPosture = contracts.enum("isolation-posture") + OwnershipClass = contracts.enum("ownership") + PatchEvidenceKind = contracts.enum("patch-evidence-kind") + PeachBoundaryType = contracts.enum("peach-boundary-type") + PqcBlastRadius = contracts.enum("pqc-blast-radius") + PqcClassification = contracts.enum("pqc-classification") + PqcReadinessBucket = contracts.enum("pqc-readiness-bucket") + PqcRemediationCategory = contracts.enum("pqc-remediation-category") + RefKind = contracts.enum("ref-kind") + RemediationEffort = contracts.enum("remediation-effort") + RemediationStatus = contracts.enum("remediation-status") + RestatementReason = contracts.enum("restatement-reason") + ReviewItemStatus = contracts.enum("review-item-status") + ReviewReason = contracts.enum("layer-review-queue-reason") + RiskRatingBand = contracts.enum("risk-rating-band") + ScopeBindingMode = contracts.enum("scope-binding-mode") + Severity = contracts.enum("severity") + SourceType = contracts.enum("source-type") + TenancyProfile = contracts.enum("tenancy-profile") + ThreatActor = contracts.enum("threat-actor") + ThreatImpact = contracts.enum("threat-impact") + ThreatLikelihood = contracts.enum("threat-likelihood") + ThreatStatus = contracts.enum("threat-status") + ValidationAdapterKind = contracts.enum("validation-adapter") + ValidationApprovalMode = contracts.enum("validation-approval-mode") + ValidationTechnique = contracts.enum("validation-technique") + ValidationVerdict = contracts.enum("validation-verdict") + Validity = contracts.enum("validity") + Verdict = contracts.enum("verdict") + VerificationVerdict = contracts.enum("verification-verdict") diff --git a/src/traust_core/v1/providers/analysis.py b/src/traust_core/v1/providers/analysis.py new file mode 100644 index 00000000..492cf0ff --- /dev/null +++ b/src/traust_core/v1/providers/analysis.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Protocol + +from traust_core.v1.models.exposure import Advisory, Dependency, ImpactEvidence +from traust_core.v1.providers.base import Readiness, Result +from traust_core.v1.providers.tools import ToolRequest + + +class DependencyExtractor(Protocol): + name: str + + def check(self) -> Readiness: ... + + def extract(self, request: ToolRequest) -> Result[list[Dependency]]: ... + + +class ImpactAnalyzer(Protocol): + name: str + + def check(self) -> Readiness: ... + + def evidence( + self, advisory: Advisory, request: ToolRequest + ) -> Result[list[ImpactEvidence]]: ... diff --git a/src/traust_core/v1/providers/base.py b/src/traust_core/v1/providers/base.py index 9a3be2e2..b492a1b3 100644 --- a/src/traust_core/v1/providers/base.py +++ b/src/traust_core/v1/providers/base.py @@ -25,7 +25,7 @@ class Readiness(Dto): detail: str = "" -class Provenance(Dto): +class Acquisition(Dto): provider: str acquired_at: datetime version: str | None = None @@ -36,7 +36,7 @@ class Provenance(Dto): @dataclass(frozen=True, slots=True) class Result(Generic[T]): data: T - provenance: Provenance + acquisition: Acquisition class Provider(Protocol[Req, Res]): diff --git a/src/traust_core/v1/providers/collaboration.py b/src/traust_core/v1/providers/collaboration.py new file mode 100644 index 00000000..77c41f64 --- /dev/null +++ b/src/traust_core/v1/providers/collaboration.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from typing import Protocol +from uuid import UUID + +from traust_core.v1.models.attestation import Comment +from traust_core.v1.models.base import Dto +from traust_core.v1.models.remediation import ExternalRef +from traust_core.v1.providers.base import Readiness, Result + + +class TrackerIssue(Dto): ... + + +class ProductDocument(Dto): ... + + +class CommentSource(Protocol): + name: str + + def check(self) -> Readiness: ... + + def comments(self, ref: ExternalRef) -> Result[list[Comment]]: ... + + +class TrackerProvider(Protocol): + name: str + + def check(self) -> Readiness: ... + + def issues(self, ref: ExternalRef) -> Result[list[TrackerIssue]]: ... + + def link(self, issue: ExternalRef, target: ExternalRef) -> None: ... + + +class DocSource(Protocol): + name: str + + def check(self) -> Readiness: ... + + def fetch(self, product_id: UUID) -> Result[list[ProductDocument]]: ... diff --git a/src/traust_core/v1/providers/feeds.py b/src/traust_core/v1/providers/feeds.py index 736ba2ac..3edee9ae 100644 --- a/src/traust_core/v1/providers/feeds.py +++ b/src/traust_core/v1/providers/feeds.py @@ -4,6 +4,7 @@ from typing import Protocol, TypeVar from traust_core.v1.models.base import Dto +from traust_core.v1.models.exposure import Advisory, Package, Version from traust_core.v1.providers.base import Readiness, Result Rec = TypeVar("Rec", covariant=True) @@ -29,3 +30,11 @@ def refresh(self) -> FeedStatus: ... def status(self) -> FeedStatus: ... def read(self) -> Result[Rec]: ... + + +class AdvisoryLookup(Protocol): + name: str + + def check(self) -> Readiness: ... + + def query(self, package: Package, version: Version) -> Result[list[Advisory]]: ... diff --git a/src/traust_core/v1/providers/identity.py b/src/traust_core/v1/providers/identity.py new file mode 100644 index 00000000..476ab23e --- /dev/null +++ b/src/traust_core/v1/providers/identity.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Protocol +from uuid import UUID + +from traust_core.v1.models.attestation import Credential, DirectoryStatus +from traust_core.v1.models.common import Principal +from traust_core.v1.providers.base import Readiness + + +# NEED(identity): "verified" means the Context factory wired a verifying provider from config +# (OIDC, directory); Principal.verified_by records which. No ldap_verified-style flags in documents. +class IdentityProvider(Protocol): + def check(self) -> Readiness: ... + + def authenticate(self, credential: Credential) -> Principal: ... + + def get(self, principal_id: UUID) -> Principal: ... + + +class Directory(Protocol): + def check(self) -> Readiness: ... + + def status(self, principal: Principal) -> DirectoryStatus: ... diff --git a/src/traust_core/v1/providers/ledger.py b/src/traust_core/v1/providers/ledger.py new file mode 100644 index 00000000..d41d172a --- /dev/null +++ b/src/traust_core/v1/providers/ledger.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Protocol +from uuid import UUID + +from traust_core.v1.models.ledger import AppendReceipt, Checkpoint, LedgerEvent, VerifyResult +from traust_core.v1.providers.base import Readiness + + +# NEED(ledger-adapter): the one implementation = core repository for events/layers in the +# configured record store (SQLite for local users, Postgres on a server) + traust-ledger for +# hashing, Merkle and signing. No file backend, no layer snapshots, no ledger REST hop. +class LedgerClient(Protocol): + def check(self) -> Readiness: ... + + def open_stream(self, owner_id: UUID) -> UUID: ... + + def append(self, stream_id: UUID, events: Sequence[LedgerEvent]) -> AppendReceipt: ... + + def checkpoint(self, stream_id: UUID) -> Checkpoint: ... + + def verify(self, checkpoint: Checkpoint) -> VerifyResult: ... diff --git a/src/traust_core/v1/providers/model_catalog.py b/src/traust_core/v1/providers/model_catalog.py new file mode 100644 index 00000000..eb99c5b2 --- /dev/null +++ b/src/traust_core/v1/providers/model_catalog.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Protocol + +from traust_core.v1.models.base import Dto +from traust_core.v1.models.operations import ModelName +from traust_core.v1.providers.base import Readiness + + +class ModelSpec(Dto): ... + + +class TokenUsage(Dto): ... + + +class ModelCatalog(Protocol): + def check(self) -> Readiness: ... + + def resolve(self, name: ModelName) -> ModelSpec: ... + + def cost(self, model: ModelName, usage: TokenUsage) -> Decimal: ... diff --git a/src/traust_core/v1/providers/runtime.py b/src/traust_core/v1/providers/runtime.py new file mode 100644 index 00000000..72ee8657 --- /dev/null +++ b/src/traust_core/v1/providers/runtime.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Protocol + +from traust_core.v1.models.assessment import Permission +from traust_core.v1.models.compliance import ComplianceTarget, InventorySnapshot +from traust_core.v1.models.validation import ClusterScope, ClusterSnapshot +from traust_core.v1.providers.base import Readiness, Result + + +class ClusterProvider(Protocol): + name: str + + def check(self) -> Readiness: ... + + def snapshot(self, scope: ClusterScope) -> Result[ClusterSnapshot]: ... + + def privileges(self, scope: ClusterScope) -> Result[list[Permission]]: ... + + +class CloudInventoryProvider(Protocol): + name: str + + def check(self) -> Readiness: ... + + def collect(self, targets: list[ComplianceTarget]) -> Result[InventorySnapshot]: ... diff --git a/src/traust_core/v1/providers/sources.py b/src/traust_core/v1/providers/sources.py index d1e7231c..8eee0a81 100644 --- a/src/traust_core/v1/providers/sources.py +++ b/src/traust_core/v1/providers/sources.py @@ -4,6 +4,8 @@ from typing import Protocol from traust_core.v1.models.base import Dto +from traust_core.v1.models.code_facts import LanguageStats +from traust_core.v1.models.portfolio import OrgName from traust_core.v1.models.values import GitSha, HttpsRepoUrl from traust_core.v1.providers.base import Readiness, Result @@ -27,6 +29,9 @@ class RepoInfo(Dto): archived: bool | None = None +class OwnerHint(Dto): ... + + class SourceProvider(Protocol): name: str @@ -37,3 +42,9 @@ def checkout(self, request: CheckoutRequest) -> Result[Workspace]: ... def info(self, repo: HttpsRepoUrl) -> Result[RepoInfo]: ... def release(self, workspace: Workspace) -> None: ... + + def owners(self, repo: HttpsRepoUrl) -> Result[list[OwnerHint]]: ... + + def list_org(self, org: OrgName) -> Result[list[HttpsRepoUrl]]: ... + + def languages(self, repo: HttpsRepoUrl) -> Result[LanguageStats]: ... diff --git a/src/traust_core/v1/providers/tools.py b/src/traust_core/v1/providers/tools.py index 131d0c55..cf6afc41 100644 --- a/src/traust_core/v1/providers/tools.py +++ b/src/traust_core/v1/providers/tools.py @@ -4,6 +4,7 @@ from typing import Protocol, TypeVar from traust_core.v1.models.base import Dto +from traust_core.v1.models.code_facts import CodeFactDetail, CodeFactKind from traust_core.v1.providers.base import Readiness, Result Res = TypeVar("Res", covariant=True) @@ -19,3 +20,11 @@ class ToolProvider(Protocol[Res]): def check(self) -> Readiness: ... def acquire(self, request: ToolRequest) -> Result[Res]: ... + + +class FactEnumerator(Protocol): + kind: CodeFactKind + + def check(self) -> Readiness: ... + + def enumerate(self, request: ToolRequest) -> Result[list[CodeFactDetail]]: ... diff --git a/src/traust_core/v1/providers/validation.py b/src/traust_core/v1/providers/validation.py new file mode 100644 index 00000000..67a3ebc8 --- /dev/null +++ b/src/traust_core/v1/providers/validation.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Protocol + +from traust_core.v1.models.base import Dto +from traust_core.v1.models.validation import PlanStep, ValidationScope +from traust_core.v1.providers.base import Readiness, Result + + +class StepResult(Dto): ... + + +class ValidationAdapter(Protocol): + name: str + + def check(self) -> Readiness: ... + + def execute(self, step: PlanStep, scope: ValidationScope) -> Result[StepResult]: ... diff --git a/src/traust_core/v1/rendering/__init__.py b/src/traust_core/v1/rendering/__init__.py index 4f7e488c..88187bd0 100644 --- a/src/traust_core/v1/rendering/__init__.py +++ b/src/traust_core/v1/rendering/__init__.py @@ -1,3 +1,4 @@ +from traust_core.v1.rendering.html import HtmlRenderer from traust_core.v1.rendering.markdown import ( MarkdownRenderer, Renderer, @@ -9,6 +10,7 @@ ) __all__ = [ + "HtmlRenderer", "MarkdownRenderer", "Renderer", "Report", diff --git a/src/traust_core/v1/rendering/html.py b/src/traust_core/v1/rendering/html.py new file mode 100644 index 00000000..3e5027a0 --- /dev/null +++ b/src/traust_core/v1/rendering/html.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from traust_core.v1.rendering.markdown import Report + + +class HtmlRenderer: + media_type = "text/html" + + def render(self, report: Report) -> bytes: + raise NotImplementedError("stub: replaces 5 render_html copies, reporting/render.py") diff --git a/src/traust_core/v1/repositories/artifacts.py b/src/traust_core/v1/repositories/artifacts.py new file mode 100644 index 00000000..64a87872 --- /dev/null +++ b/src/traust_core/v1/repositories/artifacts.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import Protocol +from uuid import UUID + +from traust_core.v1.models.base import Model +from traust_core.v1.models.common import ( + ArtifactName, + ArtifactRef, + Page, + PageRequest, + Provenance, +) + + +class ArtifactFilter(Model): ... + + +# NEED(storage): local mode = SQLite as the index + files for blobs; server mode = Postgres +# + object storage. Same IDs both ways: rows point at blobs by UUID, never by path. +# NEED(query-first): a document read whole stays a blob a service loads on demand; a field +# becomes a column only when a service filters, groups, sorts or joins on it (D1). +class ArtifactStore(Protocol): + def put(self, name: ArtifactName, payload: bytes) -> ArtifactRef: ... + def get(self, artifact_id: UUID) -> bytes: ... + def stat(self, artifact_id: UUID) -> ArtifactRef: ... + + +class ArtifactIndex(Protocol): + def record(self, ref: ArtifactRef, provenance: Provenance) -> None: ... + def get(self, artifact_id: UUID) -> ArtifactRef: ... + def list(self, filter: ArtifactFilter, page: PageRequest) -> Page[ArtifactRef]: ... diff --git a/src/traust_core/v1/repositories/assessment.py b/src/traust_core/v1/repositories/assessment.py index 1902b493..487d6d67 100644 --- a/src/traust_core/v1/repositories/assessment.py +++ b/src/traust_core/v1/repositories/assessment.py @@ -1,12 +1,47 @@ from __future__ import annotations +from collections.abc import Sequence +from datetime import datetime from typing import Protocol +from uuid import UUID +from traust_core.v1.models.assessment import ( + Assessment, + AssessmentFilter, + AssessmentKind, + Population, + Threat, + ThreatFilter, +) +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.pqc import PqcReadiness from traust_core.v1.repositories.unit_of_work import UnitOfWork -class AssessmentRepository(Protocol): ... +class AssessmentRepository(Protocol): + def get(self, assessment_id: UUID) -> Assessment: ... + def add(self, assessment: Assessment) -> Assessment: ... + def latest(self, code_line_id: UUID, kind: AssessmentKind) -> Assessment | None: ... + def list(self, filter: AssessmentFilter, page: PageRequest) -> Page[Assessment]: ... + def population(self, as_of: datetime, filter: AssessmentFilter) -> Population: ... + + +class ThreatRepository(Protocol): + def add_many(self, threats: Sequence[Threat]) -> list[Threat]: ... + def list(self, filter: ThreatFilter, page: PageRequest) -> Page[Threat]: ... + + +class PqcRepository(Protocol): + def add(self, readiness: PqcReadiness) -> PqcReadiness: ... + def update(self, readiness: PqcReadiness) -> PqcReadiness: ... + def get(self, assessment_id: UUID) -> PqcReadiness: ... + def list(self, page: PageRequest) -> Page[PqcReadiness]: ... class AssessmentUnitOfWork(UnitOfWork, Protocol): assessments: AssessmentRepository + threats: ThreatRepository + + +class PqcUnitOfWork(UnitOfWork, Protocol): + pqc: PqcRepository diff --git a/src/traust_core/v1/repositories/attestation.py b/src/traust_core/v1/repositories/attestation.py index cff71900..8316926d 100644 --- a/src/traust_core/v1/repositories/attestation.py +++ b/src/traust_core/v1/repositories/attestation.py @@ -1,12 +1,27 @@ from __future__ import annotations from typing import Protocol +from uuid import UUID +from traust_core.v1.models.attestation import Attestation, ReviewItem, ReviewSubject +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.findings import ReviewFilter from traust_core.v1.repositories.unit_of_work import UnitOfWork -class AttestationRepository(Protocol): ... +class ReviewItemRepository(Protocol): + def get(self, review_item_id: UUID) -> ReviewItem: ... + def add(self, review_item: ReviewItem) -> ReviewItem: ... + def update(self, review_item: ReviewItem) -> ReviewItem: ... + def list(self, filter: ReviewFilter, page: PageRequest) -> Page[ReviewItem]: ... + + +class AttestationRepository(Protocol): + def get(self, attestation_id: UUID) -> Attestation: ... + def add(self, attestation: Attestation) -> Attestation: ... + def list_for(self, subject: ReviewSubject) -> list[Attestation]: ... class AttestationUnitOfWork(UnitOfWork, Protocol): + review_items: ReviewItemRepository attestations: AttestationRepository diff --git a/src/traust_core/v1/repositories/code_facts.py b/src/traust_core/v1/repositories/code_facts.py index 93abb80f..54a86de7 100644 --- a/src/traust_core/v1/repositories/code_facts.py +++ b/src/traust_core/v1/repositories/code_facts.py @@ -1,12 +1,22 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Protocol +from uuid import UUID +from traust_core.v1.models.code_facts import CodeFact, CodeFactKind, SymbolFact, SymbolQuery +from traust_core.v1.models.common import Location, Page, PageRequest from traust_core.v1.repositories.unit_of_work import UnitOfWork -class CodeFactsRepository(Protocol): ... +class CodeFactRepository(Protocol): + def add_many(self, facts: Sequence[CodeFact]) -> list[CodeFact]: ... + def list( + self, revision_id: UUID, kinds: Sequence[CodeFactKind], page: PageRequest + ) -> Page[CodeFact]: ... + def find_symbols(self, revision_id: UUID, query: SymbolQuery) -> list[SymbolFact]: ... + def references(self, revision_id: UUID, query: SymbolQuery) -> list[Location]: ... class CodeFactsUnitOfWork(UnitOfWork, Protocol): - code_facts: CodeFactsRepository + code_facts: CodeFactRepository diff --git a/src/traust_core/v1/repositories/compliance.py b/src/traust_core/v1/repositories/compliance.py index f435397f..d39de12d 100644 --- a/src/traust_core/v1/repositories/compliance.py +++ b/src/traust_core/v1/repositories/compliance.py @@ -1,12 +1,50 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Protocol +from uuid import UUID +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.compliance import ( + Adr, + ComplianceAssessment, + ComplianceFilter, + ComplianceScope, + DocVariance, + DocVarianceFilter, + ScopeFilter, +) +from traust_core.v1.models.vocabulary import ComplianceFramework from traust_core.v1.repositories.unit_of_work import UnitOfWork -class ComplianceRepository(Protocol): ... +class ComplianceScopeRepository(Protocol): + def get(self, scope_id: UUID) -> ComplianceScope: ... + def add(self, scope: ComplianceScope) -> ComplianceScope: ... + def update(self, scope: ComplianceScope) -> ComplianceScope: ... + def list(self, filter: ScopeFilter, page: PageRequest) -> Page[ComplianceScope]: ... + + +class ComplianceAssessmentRepository(Protocol): + def add(self, assessment: ComplianceAssessment) -> ComplianceAssessment: ... + def latest( + self, scope_id: UUID, framework: ComplianceFramework + ) -> ComplianceAssessment | None: ... + def list(self, filter: ComplianceFilter, page: PageRequest) -> Page[ComplianceAssessment]: ... + + +class AdrRepository(Protocol): + def replace_for(self, product_id: UUID, adrs: Sequence[Adr]) -> list[Adr]: ... + def list_for(self, product_id: UUID) -> list[Adr]: ... + + +class DocVarianceRepository(Protocol): + def add_many(self, variances: Sequence[DocVariance]) -> list[DocVariance]: ... + def list(self, filter: DocVarianceFilter, page: PageRequest) -> Page[DocVariance]: ... class ComplianceUnitOfWork(UnitOfWork, Protocol): - compliance: ComplianceRepository + scopes: ComplianceScopeRepository + assessments: ComplianceAssessmentRepository + adrs: AdrRepository + doc_variances: DocVarianceRepository diff --git a/src/traust_core/v1/repositories/detection_quality.py b/src/traust_core/v1/repositories/detection_quality.py index a2457e8f..33748ec6 100644 --- a/src/traust_core/v1/repositories/detection_quality.py +++ b/src/traust_core/v1/repositories/detection_quality.py @@ -1,12 +1,44 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Protocol +from uuid import UUID +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.detection_quality import ( + BenchmarkFilter, + BenchmarkRun, + BenchmarkTarget, + CriticalMiss, + MissFilter, + RuleDraft, + RuleDraftStatus, + RulePack, +) from traust_core.v1.repositories.unit_of_work import UnitOfWork -class DetectionQualityRepository(Protocol): ... +class BenchmarkRepository(Protocol): + def add_targets(self, targets: Sequence[BenchmarkTarget]) -> list[BenchmarkTarget]: ... + def targets(self, target_ids: Sequence[UUID]) -> list[BenchmarkTarget]: ... + def add_run(self, run: BenchmarkRun) -> BenchmarkRun: ... + def runs(self, filter: BenchmarkFilter) -> list[BenchmarkRun]: ... + + +class RuleRepository(Protocol): + def add_pack(self, pack: RulePack) -> RulePack: ... + def packs(self) -> list[RulePack]: ... + def add_draft(self, draft: RuleDraft) -> RuleDraft: ... + def update_draft(self, draft: RuleDraft) -> RuleDraft: ... + def drafts(self, status: RuleDraftStatus) -> list[RuleDraft]: ... + + +class MissRepository(Protocol): + def add_many(self, misses: Sequence[CriticalMiss]) -> list[CriticalMiss]: ... + def list(self, filter: MissFilter, page: PageRequest) -> Page[CriticalMiss]: ... class DetectionQualityUnitOfWork(UnitOfWork, Protocol): - detection_quality: DetectionQualityRepository + benchmarks: BenchmarkRepository + rules: RuleRepository + misses: MissRepository diff --git a/src/traust_core/v1/repositories/exposure.py b/src/traust_core/v1/repositories/exposure.py index 7fba6279..cc12e1dd 100644 --- a/src/traust_core/v1/repositories/exposure.py +++ b/src/traust_core/v1/repositories/exposure.py @@ -1,16 +1,55 @@ from __future__ import annotations +from collections.abc import Sequence from typing import Protocol - +from uuid import UUID + +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.exposure import ( + Advisory, + AdvisoryIdentifier, + AdvisorySymbols, + Dependency, + FeedName, + FeedRun, + ImpactAnalysis, + ImpactFilter, + Package, + Version, +) from traust_core.v1.repositories.unit_of_work import UnitOfWork -class AdvisoryRepository(Protocol): ... +class AdvisoryRepository(Protocol): + def get(self, advisory_id: UUID) -> Advisory: ... + def find_by_identifier(self, identifier: AdvisoryIdentifier) -> Advisory | None: ... + def add(self, advisory: Advisory) -> Advisory: ... + def update(self, advisory: Advisory) -> Advisory: ... + def list_affecting(self, package: Package, version: Version) -> list[Advisory]: ... + def put_symbols(self, symbols: AdvisorySymbols) -> None: ... + def symbols(self, advisory_id: UUID) -> AdvisorySymbols | None: ... + + +class FeedRunRepository(Protocol): + def last(self, feed: FeedName) -> FeedRun | None: ... + def add(self, run: FeedRun) -> FeedRun: ... + + +class DependencyRepository(Protocol): + def replace_for( + self, revision_id: UUID, dependencies: Sequence[Dependency] + ) -> list[Dependency]: ... + def list_for(self, revision_id: UUID) -> list[Dependency]: ... + def dependents(self, package: Package) -> list[UUID]: ... -class DependencyGraphRepository(Protocol): ... +class ImpactRepository(Protocol): + def add(self, analysis: ImpactAnalysis) -> ImpactAnalysis: ... + def list(self, filter: ImpactFilter, page: PageRequest) -> Page[ImpactAnalysis]: ... class ExposureUnitOfWork(UnitOfWork, Protocol): advisories: AdvisoryRepository - dependency_graph: DependencyGraphRepository + feed_runs: FeedRunRepository + dependencies: DependencyRepository + impacts: ImpactRepository diff --git a/src/traust_core/v1/repositories/findings.py b/src/traust_core/v1/repositories/findings.py index 8334b1f2..0b06f099 100644 --- a/src/traust_core/v1/repositories/findings.py +++ b/src/traust_core/v1/repositories/findings.py @@ -5,18 +5,49 @@ from typing import Protocol from uuid import UUID -from traust_core.v1.models.artifacts import Severity -from traust_core.v1.models.findings import Decision, Disposition, Finding +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.findings import ( + Decision, + Disposition, + Finding, + FindingFilter, + FindingView, + Occurrence, +) +from traust_core.v1.models.integrity import Fingerprint +from traust_core.v1.models.vocabulary import Severity from traust_core.v1.repositories.unit_of_work import UnitOfWork class FindingRepository(Protocol): - def current(self, repository_id: UUID) -> list[Finding]: ... - def history(self, finding_id: UUID) -> list[Decision]: ... - def disposition(self, finding_id: UUID) -> Disposition: ... - def baseline(self, repository_id: UUID, as_of: datetime) -> list[Finding]: ... - def open_count(self, repository_id: UUID, severities: Sequence[Severity]) -> int: ... # type: ignore[valid-type] + def get(self, finding_id: UUID) -> Finding: ... + def find_by_match_key(self, repository_id: UUID, key: Fingerprint) -> Finding | None: ... + def add(self, finding: Finding) -> Finding: ... + def update(self, finding: Finding) -> Finding: ... + + +class OccurrenceRepository(Protocol): + def add(self, occurrence: Occurrence) -> Occurrence: ... + def list_for_finding(self, finding_id: UUID) -> list[Occurrence]: ... + def list_for_assessment(self, assessment_id: UUID) -> list[Occurrence]: ... + + +class DecisionRepository(Protocol): + def add(self, decision: Decision) -> Decision: ... + def list_for_finding(self, finding_id: UUID) -> list[Decision]: ... + def list_since(self, since: datetime, page: PageRequest) -> Page[Decision]: ... + + +# Severity/SLA are stored at write time (repos never run policies); rebuild_dispositions refreshes. +class DispositionView(Protocol): + def get(self, finding_id: UUID, code_line_id: UUID) -> Disposition: ... + def put(self, disposition: Disposition) -> None: ... + def list(self, filter: FindingFilter, page: PageRequest) -> Page[FindingView]: ... + def open_count(self, repository_id: UUID, severities: Sequence[Severity]) -> int: ... class FindingUnitOfWork(UnitOfWork, Protocol): findings: FindingRepository + occurrences: OccurrenceRepository + decisions: DecisionRepository + dispositions: DispositionView diff --git a/src/traust_core/v1/repositories/migrations.py b/src/traust_core/v1/repositories/migrations.py new file mode 100644 index 00000000..0154d6e4 --- /dev/null +++ b/src/traust_core/v1/repositories/migrations.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import NewType + +from sqlalchemy.engine import Engine + +SchemaVersion = NewType("SchemaVersion", str) + + +# NEED(local->server): moving from local (SQLite + dir) to Postgres + object storage is one +# step: copy rows and blobs as-is. UUID keys mean nothing is re-keyed or re-linked. +class Migrations: + def __init__(self, engine: Engine) -> None: + self._engine = engine + + def current(self) -> SchemaVersion: + raise NotImplementedError("stub: replaces traust_storage_meta / schema_revision") + + def apply(self) -> SchemaVersion: + raise NotImplementedError("stub: replaces contracts migrations/*.sql runners") diff --git a/src/traust_core/v1/repositories/object_store.py b/src/traust_core/v1/repositories/object_store.py index c5c505ab..063a66b1 100644 --- a/src/traust_core/v1/repositories/object_store.py +++ b/src/traust_core/v1/repositories/object_store.py @@ -36,6 +36,8 @@ class ObjectRef: size: int +# NEED(vendor-agnostic): local disk and S3-compatible object storage behind this one port. +# Vendor SDKs live in optional adapters selected by config; core never imports one. class ObjectStore(Protocol): def put(self, key: ObjectKey, data: bytes) -> ObjectRef: ... def get(self, ref: ObjectRef) -> bytes: ... diff --git a/src/traust_core/v1/repositories/operations.py b/src/traust_core/v1/repositories/operations.py index 89550eee..b2dad70f 100644 --- a/src/traust_core/v1/repositories/operations.py +++ b/src/traust_core/v1/repositories/operations.py @@ -1,16 +1,48 @@ from __future__ import annotations +from datetime import datetime from typing import Protocol - +from uuid import UUID + +from traust_core.v1.models.common import Page, PageRequest, TimeWindow +from traust_core.v1.models.operations import ( + Lease, + Order, + OrderEvent, + OrderFilter, + SpendDimension, + SpendEvent, + SpendFilter, + SpendRollup, + WorkerId, +) from traust_core.v1.repositories.unit_of_work import UnitOfWork -class OpsRepository(Protocol): ... +class OrderRepository(Protocol): + def get(self, order_id: UUID) -> Order: ... + def add(self, order: Order) -> Order: ... + def update(self, order: Order) -> Order: ... + def claim(self, worker: WorkerId, now: datetime) -> Lease | None: ... + def list(self, filter: OrderFilter, page: PageRequest) -> Page[Order]: ... + + +class OrderEventRepository(Protocol): + def append(self, event: OrderEvent) -> OrderEvent: ... + def list_for(self, order_id: UUID) -> list[OrderEvent]: ... -class SpendRepository(Protocol): ... +class SpendRepository(Protocol): + def append(self, event: SpendEvent) -> SpendEvent: ... + def list(self, filter: SpendFilter, page: PageRequest) -> Page[SpendEvent]: ... + def aggregate(self, filter: SpendFilter, group_by: SpendDimension) -> list[SpendRollup]: ... + def unattributed(self, window: TimeWindow) -> list[SpendEvent]: ... class OpsUnitOfWork(UnitOfWork, Protocol): - orders: OpsRepository + orders: OrderRepository + order_events: OrderEventRepository + + +class SpendUnitOfWork(UnitOfWork, Protocol): spend: SpendRepository diff --git a/src/traust_core/v1/repositories/portfolio.py b/src/traust_core/v1/repositories/portfolio.py index db726267..ea89b17f 100644 --- a/src/traust_core/v1/repositories/portfolio.py +++ b/src/traust_core/v1/repositories/portfolio.py @@ -1,12 +1,71 @@ from __future__ import annotations from typing import Protocol +from uuid import UUID +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.portfolio import ( + CodeLine, + Component, + ComponentFilter, + Product, + ProductSlug, + RefName, + Release, + Repository, + RepositoryFilter, + Revision, +) +from traust_core.v1.models.values import GitSha, HttpsRepoUrl from traust_core.v1.repositories.unit_of_work import UnitOfWork -class PortfolioRepository(Protocol): ... +class ProductRepository(Protocol): + def get(self, product_id: UUID) -> Product: ... + def find_by_slug(self, slug: ProductSlug) -> Product | None: ... + def add(self, product: Product) -> Product: ... + def update(self, product: Product) -> Product: ... + def list(self, page: PageRequest) -> Page[Product]: ... + + +class RepositoryRepository(Protocol): + def get(self, repository_id: UUID) -> Repository: ... + def find_by_url(self, url: HttpsRepoUrl) -> Repository | None: ... + def add(self, repository: Repository) -> Repository: ... + def update(self, repository: Repository) -> Repository: ... + def list(self, filter: RepositoryFilter, page: PageRequest) -> Page[Repository]: ... + + +class CodeLineRepository(Protocol): + def get(self, code_line_id: UUID) -> CodeLine: ... + def find_by_ref(self, repository_id: UUID, ref: RefName) -> CodeLine | None: ... + def add(self, code_line: CodeLine) -> CodeLine: ... + def list_for(self, repository_id: UUID) -> list[CodeLine]: ... + + +class RevisionRepository(Protocol): + def get(self, revision_id: UUID) -> Revision: ... + def find_by_sha(self, repository_id: UUID, sha: GitSha) -> Revision | None: ... + def add(self, revision: Revision) -> Revision: ... + + +class ComponentRepository(Protocol): + def get(self, component_id: UUID) -> Component: ... + def find(self, product_id: UUID, code_line_id: UUID) -> Component | None: ... + def add(self, component: Component) -> Component: ... + def update(self, component: Component) -> Component: ... + def list(self, filter: ComponentFilter, page: PageRequest) -> Page[Component]: ... + + +class ReleaseRepository(Protocol): + def add(self, release: Release) -> Release: ... + def list_for(self, component_id: UUID) -> list[Release]: ... class PortfolioUnitOfWork(UnitOfWork, Protocol): - portfolio: PortfolioRepository + products: ProductRepository + repositories: RepositoryRepository + code_lines: CodeLineRepository + revisions: RevisionRepository + components: ComponentRepository + releases: ReleaseRepository diff --git a/src/traust_core/v1/repositories/remediation.py b/src/traust_core/v1/repositories/remediation.py index 1d48df8a..8cec6ba6 100644 --- a/src/traust_core/v1/repositories/remediation.py +++ b/src/traust_core/v1/repositories/remediation.py @@ -1,12 +1,40 @@ from __future__ import annotations from typing import Protocol +from uuid import UUID +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.remediation import ( + FleetFix, + Verification, + VerificationFilter, + WorkItem, + WorkItemFilter, +) from traust_core.v1.repositories.unit_of_work import UnitOfWork -class RemediationRepository(Protocol): ... +class WorkItemRepository(Protocol): + def get(self, work_item_id: UUID) -> WorkItem: ... + def add(self, work_item: WorkItem) -> WorkItem: ... + def update(self, work_item: WorkItem) -> WorkItem: ... + def list(self, filter: WorkItemFilter, page: PageRequest) -> Page[WorkItem]: ... + def next_pending(self, filter: WorkItemFilter) -> WorkItem | None: ... + + +class FleetFixRepository(Protocol): + def get(self, fleet_fix_id: UUID) -> FleetFix: ... + def add(self, fleet_fix: FleetFix) -> FleetFix: ... + def update(self, fleet_fix: FleetFix) -> FleetFix: ... + + +class VerificationRepository(Protocol): + def add(self, verification: Verification) -> Verification: ... + def list_for(self, work_item_id: UUID) -> list[Verification]: ... + def list(self, filter: VerificationFilter, page: PageRequest) -> Page[Verification]: ... class RemediationUnitOfWork(UnitOfWork, Protocol): - remediations: RemediationRepository + work_items: WorkItemRepository + fleet_fixes: FleetFixRepository + verifications: VerificationRepository diff --git a/src/traust_core/v1/repositories/sql.py b/src/traust_core/v1/repositories/sql.py index 5aa7b717..02154e2a 100644 --- a/src/traust_core/v1/repositories/sql.py +++ b/src/traust_core/v1/repositories/sql.py @@ -55,8 +55,13 @@ def process_result_value(self, value: Any, dialect: sa.Dialect) -> datetime | No def apply_schema(engine: Engine, statements: Iterable[str]) -> None: try: with engine.begin() as conn: - for stmt in statements: - conn.exec_driver_sql(stmt) + # Raw cursor, no params: a '%' in DDL text is never parsed as a placeholder. + cursor = conn.connection.cursor() + try: + for stmt in statements: + cursor.execute(stmt) + finally: + cursor.close() except SQLAlchemyError as e: raise RepositoryError(f"apply schema failed: {e}") from e diff --git a/src/traust_core/v1/repositories/triage.py b/src/traust_core/v1/repositories/triage.py index 11e425b2..b859b567 100644 --- a/src/traust_core/v1/repositories/triage.py +++ b/src/traust_core/v1/repositories/triage.py @@ -5,8 +5,8 @@ import sqlalchemy as sa -from traust_core.v1.models.artifacts import Verdict from traust_core.v1.models.findings import TriageVerdict +from traust_core.v1.models.vocabulary import Verdict from traust_core.v1.repositories.memory import InMemoryUnitOfWork from traust_core.v1.repositories.sql import STORAGE_SCHEMA, SqlRepository, SqlUnitOfWork from traust_core.v1.repositories.storage import ( diff --git a/src/traust_core/v1/repositories/validation.py b/src/traust_core/v1/repositories/validation.py index 4f628a7d..cd3d5668 100644 --- a/src/traust_core/v1/repositories/validation.py +++ b/src/traust_core/v1/repositories/validation.py @@ -1,12 +1,26 @@ from __future__ import annotations from typing import Protocol +from uuid import UUID +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.validation import ValidationAttempt, ValidationFilter, ValidationPlan from traust_core.v1.repositories.unit_of_work import UnitOfWork -class ValidationRepository(Protocol): ... +class ValidationPlanRepository(Protocol): + def get(self, plan_id: UUID) -> ValidationPlan: ... + def add(self, plan: ValidationPlan) -> ValidationPlan: ... + def update(self, plan: ValidationPlan) -> ValidationPlan: ... + def list_for(self, finding_id: UUID) -> list[ValidationPlan]: ... + + +class ValidationAttemptRepository(Protocol): + def add(self, attempt: ValidationAttempt) -> ValidationAttempt: ... + def list_for(self, plan_id: UUID) -> list[ValidationAttempt]: ... + def list(self, filter: ValidationFilter, page: PageRequest) -> Page[ValidationAttempt]: ... class ValidationUnitOfWork(UnitOfWork, Protocol): - validations: ValidationRepository + plans: ValidationPlanRepository + attempts: ValidationAttemptRepository diff --git a/src/traust_core/v1/services/__init__.py b/src/traust_core/v1/services/__init__.py index d21d6cc6..2f474810 100644 --- a/src/traust_core/v1/services/__init__.py +++ b/src/traust_core/v1/services/__init__.py @@ -1,6 +1,6 @@ from traust_core.v1.services.artifact_publishing import ( - ArtifactIndex, ArtifactPublisher, + BindingIndex, Companion, InputFormat, JsonInput, @@ -17,8 +17,8 @@ ) __all__ = [ - "ArtifactIndex", "ArtifactPublisher", + "BindingIndex", "Companion", "InputFormat", "JsonInput", diff --git a/src/traust_core/v1/services/artifact_publishing.py b/src/traust_core/v1/services/artifact_publishing.py index c2df4449..f299281b 100644 --- a/src/traust_core/v1/services/artifact_publishing.py +++ b/src/traust_core/v1/services/artifact_publishing.py @@ -3,14 +3,17 @@ from collections.abc import Callable from dataclasses import dataclass, field from typing import Any, Generic, Protocol, TypeVar +from uuid import UUID from traust_core.v1.errors import ConfigError, DocumentError, Issue, TraustError from traust_core.v1.models.analysis_results import ResultKind, Subject from traust_core.v1.models.artifacts import Artifact from traust_core.v1.models.base import Dto +from traust_core.v1.models.common import ArtifactRef, Page, PageRequest from traust_core.v1.models.storage import BindingContext from traust_core.v1.providers.clock import Clock from traust_core.v1.repositories.analysis_results import AnalysisResultsRepository, artifact_kind +from traust_core.v1.repositories.artifacts import ArtifactFilter from traust_core.v1.repositories.object_store import ObjectRef from traust_core.v1.repositories.storage import StorageUnitOfWork from traust_core.v1.services.storage import record_artifact @@ -69,7 +72,7 @@ def names(self) -> list[str]: return sorted(self._specs) -class ArtifactIndex(Protocol): +class BindingIndex(Protocol): def record(self, artifact: Artifact, subject: Subject, ref: ObjectRef, run_id: str) -> str: ... @@ -102,7 +105,7 @@ def __init__( self, specs: PublishSpecs, results: AnalysisResultsRepository, - index: ArtifactIndex | None = None, + index: BindingIndex | None = None, ) -> None: self._specs = specs self._results = results @@ -128,3 +131,23 @@ def publish_artifact(self, artifact: Artifact, subject: Subject, run_id: str) -> except TraustError as e: return result.model_copy(update={"index_error": str(e)}) return result.model_copy(update={"binding_id": binding}) + + def get(self, artifact_id: UUID) -> Artifact: + raise NotImplementedError("stub: replaces AnalysisResultsRepository.read by path subject") + + def verify(self, artifact_id: UUID) -> DigestCheck: + raise NotImplementedError("stub: replaces verify_report_digests.py, ledger report digests") + + def list(self, filter: ArtifactFilter, page: PageRequest) -> Page[ArtifactRef]: + raise NotImplementedError("stub: replaces corpus/report_store listing") + + +class DigestCheck(Dto): ... + + +class Redacted(Dto): ... + + +class RedactionPolicy: + def redact(self, payload: bytes) -> Redacted: + raise NotImplementedError("stub: replaces _util/redact.py") diff --git a/src/traust_core/v1/services/assessment.py b/src/traust_core/v1/services/assessment.py index 2311573a..7a025675 100644 --- a/src/traust_core/v1/services/assessment.py +++ b/src/traust_core/v1/services/assessment.py @@ -1,42 +1,69 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime from uuid import UUID -from traust_core.v1.models.artifacts import ( - CloudConfigAuditArtifact, - PrivProfileArtifact, - SecurityAuditArtifact, - ThreatModelArtifact, +from traust_core.v1.models.artifacts import AssessmentArtifact +from traust_core.v1.models.assessment import ( + Assessment, + AssessmentFilter, + AssessmentInput, + AssessmentKind, + CoverageRow, + Freshness, + Population, + PrivilegeProfile, + Threat, + ThreatFilter, + ToolName, + ToolRun, ) -from traust_core.v1.models.assessment import Assessment, AssessmentKind, Freshness -from traust_core.v1.models.operations import JobResult -from traust_core.v1.models.storage import BindingContext +from traust_core.v1.models.common import Page, PageRequest, Provenance from traust_core.v1.repositories.assessment import AssessmentUnitOfWork +from traust_core.v1.services.findings.intake import FindingIntake class AssessmentService: - def __init__(self, uow: AssessmentUnitOfWork) -> None: + def __init__(self, uow: AssessmentUnitOfWork, intake: FindingIntake) -> None: self._uow = uow + self._intake = intake - def record_audit( - self, audit: SecurityAuditArtifact | CloudConfigAuditArtifact, context: BindingContext - ) -> JobResult: - raise NotImplementedError("stub: replaces corpus/store_ingest.py, report_store.py") + def run_tools(self, revision_id: UUID, tools: Sequence[ToolName]) -> ToolRun: + raise NotImplementedError("stub: replaces run_checkov.py, adapters _contract_bridge") - def record_threat_model(self, model: ThreatModelArtifact, context: BindingContext) -> JobResult: - raise NotImplementedError("stub: replaces threat-model scripts, corpus/threat_model.py") + def prepare( + self, revision_id: UUID, kind: AssessmentKind, base_id: UUID | None + ) -> AssessmentInput: + raise NotImplementedError( + "stub: replaces build_diff_packet.py, resolve_baseline.py, threat-model inputs" + ) - def record_priv_profile( - self, profile: PrivProfileArtifact, context: BindingContext - ) -> JobResult: - raise NotImplementedError("stub: replaces operator-priv-profile scripts") + def record( + self, doc: AssessmentArtifact, revision_id: UUID, provenance: Provenance + ) -> Assessment: + raise NotImplementedError("stub: replaces corpus/store_ingest.py, threat_model.py") + + def get(self, assessment_id: UUID) -> Assessment: + raise NotImplementedError("stub: assessment read") def latest(self, code_line_id: UUID, kind: AssessmentKind) -> Assessment | None: - raise NotImplementedError("stub: replaces corpus/resolver.py lookups") + raise NotImplementedError("stub: replaces corpus/resolver latest report") + + def list(self, filter: AssessmentFilter, page: PageRequest) -> Page[Assessment]: + raise NotImplementedError("stub: replaces corpus/resolver.walk_reports") - def population(self, as_of: datetime) -> list[Assessment]: - raise NotImplementedError("stub: replaces census/build_census.py, corpus/resolver.py") + def population(self, as_of: datetime, filter: AssessmentFilter) -> Population: + raise NotImplementedError("stub: replaces census/build_census.py, corpus/resolver") def freshness(self, code_line_id: UUID) -> Freshness: - raise NotImplementedError("stub: replaces audit_age_days/churn_ratio in traust#58") + raise NotImplementedError("stub: replaces build_rescan_worklist audit_age / churn") + + def threats(self, filter: ThreatFilter, page: PageRequest) -> Page[Threat]: + raise NotImplementedError("stub: replaces threat_current, threat-register parse_model") + + def coverage(self, filter: AssessmentFilter) -> list[CoverageRow]: + raise NotImplementedError("stub: replaces build_coverage_gap_rollup.py") + + def privileges(self, filter: AssessmentFilter) -> list[PrivilegeProfile]: + raise NotImplementedError("stub: replaces operator_privilege, load_least_priv") diff --git a/src/traust_core/v1/services/attestation.py b/src/traust_core/v1/services/attestation.py index 50a5a4b2..232ef3e7 100644 --- a/src/traust_core/v1/services/attestation.py +++ b/src/traust_core/v1/services/attestation.py @@ -2,21 +2,76 @@ from uuid import UUID -from traust_core.v1.models.attestation import Attestation, ReviewItem +from traust_core.v1.models.attestation import ( + Attestation, + AttestationDecision, + Comment, + CommentSourceName, + Credential, + ReviewItem, + ReviewSubject, + Statement, + TrustTier, +) +from traust_core.v1.models.common import Page, PageRequest, Principal +from traust_core.v1.models.findings import ReviewFilter +from traust_core.v1.models.portfolio import Ownership +from traust_core.v1.models.remediation import ExternalRef +from traust_core.v1.models.vocabulary import ReviewReason +from traust_core.v1.providers.collaboration import CommentSource +from traust_core.v1.providers.identity import IdentityProvider from traust_core.v1.repositories.attestation import AttestationUnitOfWork +from traust_core.v1.services.ledger import LedgerService + + +class StatementPolicy: + def trust( + self, comment: Comment, author: Principal | None, ownership: Ownership | None + ) -> TrustTier: + raise NotImplementedError("stub: replaces track-findings human-statement trust tiers") class AttestationService: - def __init__(self, uow: AttestationUnitOfWork) -> None: + def __init__( + self, + uow: AttestationUnitOfWork, + ledger: LedgerService | None, + comments: CommentSource, + policy: StatementPolicy, + ) -> None: self._uow = uow + self._ledger = ledger + self._comments = comments + self._policy = policy + + def request(self, subject: ReviewSubject, reason: ReviewReason) -> ReviewItem: + raise NotImplementedError("stub: replaces countersign inbox writes, fleet-fix batch review") + + def attest( + self, + review_item_id: UUID, + principal: Principal, + decision: AttestationDecision, + statement: Statement | None, + ) -> Attestation: + raise NotImplementedError("stub: replaces countersign.py, ledger cli countersign") + + def import_statements(self, source: CommentSourceName, ref: ExternalRef) -> list[Attestation]: + raise NotImplementedError("stub: replaces track-findings MR/Jira comment reads") + + def pending(self, filter: ReviewFilter, page: PageRequest) -> Page[ReviewItem]: + raise NotImplementedError("stub: replaces countersign.discover_pending") + + def for_subject(self, subject: ReviewSubject) -> list[Attestation]: + raise NotImplementedError("stub: replaces countersign baseline_for") + - def request(self, subject_id: UUID, reason: str) -> ReviewItem: - raise NotImplementedError("stub: replaces review-queue writes in countersign.py") +class IdentityService: + def __init__(self, provider: IdentityProvider) -> None: + self._provider = provider - def pending(self) -> list[ReviewItem]: - raise NotImplementedError("stub: replaces countersign.py inbox") + def authenticate(self, credential: Credential) -> Principal: + raise NotImplementedError("stub: replaces compliance_scope_intake.verify_identity") - def attest(self, review_item_id: UUID, actor: str, verified: bool) -> Attestation: - raise NotImplementedError( - "stub: replaces countersign.py signoff and track-findings trust tiers" - ) + def get(self, principal_id: UUID) -> Principal: + raise NotImplementedError("stub: principal lookup (local user / DB / OIDC)") diff --git a/src/traust_core/v1/services/code_facts.py b/src/traust_core/v1/services/code_facts.py index 5b58bfe8..b199683c 100644 --- a/src/traust_core/v1/services/code_facts.py +++ b/src/traust_core/v1/services/code_facts.py @@ -3,19 +3,41 @@ from collections.abc import Sequence from uuid import UUID -from traust_core.v1.models.code_facts import CodeFact -from traust_core.v1.models.operations import JobResult +from traust_core.v1.models.code_facts import ( + CodeFact, + CodeFactKind, + CollectResult, + LanguageStats, + SymbolFact, + SymbolQuery, +) +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.providers.sources import SourceProvider +from traust_core.v1.providers.tools import FactEnumerator from traust_core.v1.repositories.code_facts import CodeFactsUnitOfWork class CodeFactsService: - def __init__(self, uow: CodeFactsUnitOfWork) -> None: + def __init__( + self, + uow: CodeFactsUnitOfWork, + sources: SourceProvider, + enumerators: Sequence[FactEnumerator], + ) -> None: self._uow = uow + self._sources = sources + self._enumerators = enumerators - def enumerate(self, revision_id: UUID, kinds: Sequence[str]) -> JobResult: - raise NotImplementedError( - "stub: replaces secure-code-audit enumerators, enumerate_taint_flows.py" - ) + def collect(self, revision_id: UUID, kinds: Sequence[CodeFactKind]) -> CollectResult: + raise NotImplementedError("stub: replaces build_symbol_index, secure-code enumerators") - def symbol(self, revision_id: UUID, name: str) -> list[CodeFact]: - raise NotImplementedError("stub: replaces build_symbol_index.py, query_index.py") + def list( + self, revision_id: UUID, kinds: Sequence[CodeFactKind], page: PageRequest + ) -> Page[CodeFact]: + raise NotImplementedError("stub: code facts read") + + def symbols(self, revision_id: UUID, query: SymbolQuery) -> list[SymbolFact]: + raise NotImplementedError("stub: replaces query_index.py, build_diff_packet.callers_for") + + def language_stats(self, revision_id: UUID) -> LanguageStats: + raise NotImplementedError("stub: replaces loc-dashboard GitHub language fetch") diff --git a/src/traust_core/v1/services/compliance.py b/src/traust_core/v1/services/compliance.py index c0f8f216..45ca55d5 100644 --- a/src/traust_core/v1/services/compliance.py +++ b/src/traust_core/v1/services/compliance.py @@ -1,9 +1,29 @@ from __future__ import annotations +from typing import overload from uuid import UUID -from traust_core.v1.models.compliance import ComplianceResult, ComplianceScope -from traust_core.v1.models.operations import JobResult +from traust_core.v1.models.artifacts import ( + ComplianceAssessmentArtifact, + ComplianceScopeArtifact, + DocVarianceArtifact, +) +from traust_core.v1.models.common import ArtifactRef, Page, PageRequest, Principal, Provenance +from traust_core.v1.models.compliance import ( + Adr, + ComplianceAssessment, + ComplianceDimension, + ComplianceFilter, + ComplianceInput, + ComplianceRollup, + ComplianceScope, + DocVariance, + DocVarianceFilter, + EvaluationRun, + InventorySnapshot, + ScopeFilter, +) +from traust_core.v1.models.vocabulary import ComplianceFramework from traust_core.v1.repositories.compliance import ComplianceUnitOfWork @@ -11,11 +31,53 @@ class ComplianceService: def __init__(self, uow: ComplianceUnitOfWork) -> None: self._uow = uow - def set_scope(self, scope: ComplianceScope) -> None: - raise NotImplementedError("stub: replaces compliance_scope_intake.py, compliance/scope.py") + def set_scope(self, doc: ComplianceScopeArtifact, principal: Principal) -> ComplianceScope: + raise NotImplementedError("stub: replaces compliance_scope_intake.py") - def evaluate(self, scope_id: UUID, framework: str) -> JobResult: - raise NotImplementedError("stub: replaces compliance-check scripts") + def collect_inventory(self, scope_id: UUID) -> InventorySnapshot: + raise NotImplementedError("stub: replaces collect_cloud_inventory.py") - def latest(self, scope_id: UUID, framework: str) -> list[ComplianceResult]: - raise NotImplementedError("stub: replaces compliance/dashboard.py reads") + def evaluate(self, scope_id: UUID, framework: ComplianceFramework) -> EvaluationRun: + raise NotImplementedError("stub: replaces compliance_assert.py, run_compliance_check.py") + + def prepare(self, scope_id: UUID, framework: ComplianceFramework) -> ComplianceInput: + raise NotImplementedError("stub: replaces run_compliance_check bundle inputs") + + @overload + def record( + self, doc: ComplianceAssessmentArtifact, provenance: Provenance + ) -> ComplianceAssessment: ... + @overload + def record(self, doc: DocVarianceArtifact, provenance: Provenance) -> list[DocVariance]: ... + def record( + self, doc: ComplianceAssessmentArtifact | DocVarianceArtifact, provenance: Provenance + ) -> ComplianceAssessment | list[DocVariance]: + raise NotImplementedError( + "stub: replaces validate_compliance_assessment.py, emit_doc_variance.py" + ) + + def index_adrs(self, product_id: UUID) -> list[Adr]: + raise NotImplementedError("stub: replaces ops/index_adrs.py") + + def collect_product_docs(self, product_id: UUID) -> list[ArtifactRef]: + raise NotImplementedError("stub: replaces ops/fetch_product_docs.py") + + def get_scope(self, scope_id: UUID) -> ComplianceScope: + raise NotImplementedError("stub: replaces compliance/scope.load_scope") + + def scopes(self, filter: ScopeFilter, page: PageRequest) -> Page[ComplianceScope]: + raise NotImplementedError("stub: replaces compliance/scope.graph_product_repos") + + def latest(self, scope_id: UUID, framework: ComplianceFramework) -> ComplianceAssessment | None: + raise NotImplementedError("stub: replaces compliance/dashboard.latest_per_target_framework") + + def summary( + self, filter: ComplianceFilter, group_by: ComplianceDimension + ) -> list[ComplianceRollup]: + raise NotImplementedError("stub: replaces compliance_posture") + + def adrs(self, product_id: UUID) -> list[Adr]: + raise NotImplementedError("stub: replaces adr_registry reads") + + def doc_variances(self, filter: DocVarianceFilter, page: PageRequest) -> Page[DocVariance]: + raise NotImplementedError("stub: replaces threat-register doc_variance_cut") diff --git a/src/traust_core/v1/services/detection_quality.py b/src/traust_core/v1/services/detection_quality.py index ddd9c7dc..62d9a037 100644 --- a/src/traust_core/v1/services/detection_quality.py +++ b/src/traust_core/v1/services/detection_quality.py @@ -1,9 +1,27 @@ from __future__ import annotations +from collections.abc import Sequence +from datetime import datetime from uuid import UUID -from traust_core.v1.models.detection_quality import RulePack -from traust_core.v1.models.operations import JobResult +from traust_core.v1.models.artifacts import BenchmarkTargetArtifact +from traust_core.v1.models.common import Page, PageRequest, Provenance, TimeWindow +from traust_core.v1.models.detection_quality import ( + BenchmarkFilter, + BenchmarkRun, + BenchmarkTarget, + Calibration, + Canary, + ComplianceCalibration, + CriticalMiss, + InjectionScreen, + MissFilter, + RuleDraft, + RuleDraftStatus, + RulePack, + Scorecard, +) +from traust_core.v1.models.vocabulary import ComplianceFramework from traust_core.v1.repositories.detection_quality import DetectionQualityUnitOfWork @@ -11,17 +29,43 @@ class DetectionQualityService: def __init__(self, uow: DetectionQualityUnitOfWork) -> None: self._uow = uow - def score(self, run_id: UUID) -> JobResult: + def record(self, doc: BenchmarkTargetArtifact, provenance: Provenance) -> BenchmarkTarget: + raise NotImplementedError("stub: replaces bootstrap_benchmark_targets.py") + + def build_canaries(self, target_ids: Sequence[UUID]) -> list[Canary]: + raise NotImplementedError("stub: replaces recall-benchmark/build_canaries.py") + + def run_benchmark(self, rule_pack_id: UUID, target_ids: Sequence[UUID]) -> BenchmarkRun: raise NotImplementedError("stub: replaces match_benchmark.py, sweep/benchmark.py") - def mine_rules(self) -> list[RulePack]: - raise NotImplementedError("stub: replaces emit_rule_drafts.py, sweep/mining.py") + def screen_injection(self, artifact_id: UUID) -> InjectionScreen: + raise NotImplementedError("stub: replaces recall-benchmark/screen_injection.py") - def calibrate(self, rule_pack_id: UUID) -> JobResult: + def mine_rules(self, since: datetime) -> list[RuleDraft]: + raise NotImplementedError("stub: replaces sweep/mining, sweep/engine, emit_rule_drafts") + + def calibrate(self, subject_id: UUID) -> Calibration: raise NotImplementedError("stub: replaces calibrate_rule_pack.py, sweep/calibration.py") - def admit(self, rule_pack_id: UUID) -> JobResult: - raise NotImplementedError("stub: replaces sweep/rule_lane.py admission") + def admit_rule(self, draft_id: UUID, approval_id: UUID) -> RulePack: + raise NotImplementedError("stub: replaces sweep rule_lane admission") + + def critical_misses(self, window: TimeWindow) -> list[CriticalMiss]: + raise NotImplementedError("stub: replaces build_critical_misses.py, cve_replay_monitor.py") + + def calibrate_compliance(self, framework: ComplianceFramework) -> ComplianceCalibration: + raise NotImplementedError( + "stub: replaces calibrate_compliance, compliance_precision, score_compliance_efficacy" + ) + + def scorecards(self, filter: BenchmarkFilter) -> list[Scorecard]: + raise NotImplementedError("stub: replaces sweep/benchmark.cmd_scorecard") + + def rule_packs(self) -> list[RulePack]: + raise NotImplementedError("stub: replaces opengrep resolve_rule_pack_dir") + + def drafts(self, status: RuleDraftStatus) -> list[RuleDraft]: + raise NotImplementedError("stub: replaces sweep/engine.existing_rule_ids") - def misses(self) -> JobResult: - raise NotImplementedError("stub: replaces cve_replay_monitor.py, build_critical_misses.py") + def misses(self, filter: MissFilter, page: PageRequest) -> Page[CriticalMiss]: + raise NotImplementedError("stub: replaces critical-misses report reads") diff --git a/src/traust_core/v1/services/exposure.py b/src/traust_core/v1/services/exposure.py index 07ba7fea..d22d55b8 100644 --- a/src/traust_core/v1/services/exposure.py +++ b/src/traust_core/v1/services/exposure.py @@ -2,32 +2,87 @@ from uuid import UUID -from traust_core.v1.models.exposure import Advisory, ImpactAnalysis -from traust_core.v1.models.operations import JobResult +from traust_core.v1.models.artifacts import ImpactAnalysisArtifact +from traust_core.v1.models.common import Page, PageRequest, Provenance +from traust_core.v1.models.exposure import ( + Advisory, + AdvisoryIdentifier, + AdvisorySymbols, + AffectedDependency, + Dependency, + DependencyIngest, + DependencyProvenance, + ExposureDimension, + ExposureFilter, + ExposureRollup, + FeedName, + ImpactAnalysis, + ImpactFilter, + ImpactInput, + Package, + ProvenanceCheck, + Version, + VexStatus, +) from traust_core.v1.providers.feeds import FeedStatus from traust_core.v1.repositories.exposure import ExposureUnitOfWork +from traust_core.v1.services.findings.intake import FindingIntake class ExposureService: - def __init__(self, uow: ExposureUnitOfWork) -> None: + def __init__(self, uow: ExposureUnitOfWork, intake: FindingIntake) -> None: self._uow = uow + self._intake = intake - def refresh_feed(self, feed: str) -> FeedStatus: + def refresh_feed(self, feed: FeedName) -> FeedStatus: raise NotImplementedError("stub: replaces fetch_feeds.py, registry/feeds.py") - def resolve_advisory(self, identifier: str) -> Advisory | None: - raise NotImplementedError("stub: replaces fetch_advisory.py, adapters/osv.py") + def ingest_dependencies(self, revision_id: UUID) -> DependencyIngest: + raise NotImplementedError("stub: replaces portfolio/graph.build_deps, parsers.py") - def expand_symbols(self, advisory_id: UUID) -> JobResult: + def resolve_symbols(self, advisory_id: UUID) -> AdvisorySymbols: raise NotImplementedError( "stub: replaces resolve_advisory_symbols.py, expand_advisory_entry_points.py" ) - def analyze_impact(self, advisory_id: UUID) -> list[ImpactAnalysis]: - raise NotImplementedError("stub: replaces impact/analyzer.py, run_impact_sweep.py") + def prepare(self, advisory_id: UUID, revision_id: UUID) -> ImpactInput: + raise NotImplementedError("stub: replaces impact/analyzer evidence collection") - def dependents(self, repository_id: UUID) -> list[UUID]: - raise NotImplementedError("stub: replaces portfolio/graph.py, build_repo_graph.py") + def record(self, doc: ImpactAnalysisArtifact, provenance: Provenance) -> ImpactAnalysis: + raise NotImplementedError("stub: replaces impact/analyzer.emit_impact_analysis") - def reconcile_cve_provenance(self, finding_id: UUID) -> Advisory | None: + def verify_dependency(self, package: Package, version: Version) -> DependencyProvenance: + raise NotImplementedError("stub: replaces verify_dep_provenance.py") + + def reconcile_provenance(self, finding_id: UUID) -> ProvenanceCheck: raise NotImplementedError("stub: replaces reconcile_cve_provenance.py") + + def get_advisory(self, advisory_id: UUID) -> Advisory: + raise NotImplementedError("stub: exposure read") + + def find_advisory(self, identifier: AdvisoryIdentifier) -> Advisory | None: + raise NotImplementedError("stub: replaces fetch_advisory.py") + + def advisories_for(self, finding_id: UUID) -> list[Advisory]: + raise NotImplementedError("stub: replaces enrich_findings_cves.py") + + def affected(self, advisory_id: UUID) -> list[AffectedDependency]: + raise NotImplementedError("stub: replaces fleet_sweep.py, run_impact_sweep.py") + + def dependencies(self, revision_id: UUID) -> list[Dependency]: + raise NotImplementedError("stub: replaces portfolio-graph dependency reads") + + def dependents(self, package: Package) -> list[UUID]: + raise NotImplementedError("stub: replaces portfolio/graph dependents, smoke_deps_multi") + + def impacts(self, filter: ImpactFilter, page: PageRequest) -> Page[ImpactAnalysis]: + raise NotImplementedError("stub: replaces impact_analysis reads") + + def summary(self, filter: ExposureFilter, group_by: ExposureDimension) -> list[ExposureRollup]: + raise NotImplementedError("stub: replaces *_exposure queries, exposure_trend") + + def vex_status(self, advisory: AdvisoryIdentifier, product_id: UUID) -> VexStatus | None: + raise NotImplementedError("stub: replaces fetch_feeds.vex_product_status") + + def feed_status(self, feed: FeedName) -> FeedStatus: + raise NotImplementedError("stub: replaces registry/feeds.feed_status") diff --git a/src/traust_core/v1/services/findings/__init__.py b/src/traust_core/v1/services/findings/__init__.py index 6b23a62d..3b2fa9e8 100644 --- a/src/traust_core/v1/services/findings/__init__.py +++ b/src/traust_core/v1/services/findings/__init__.py @@ -3,9 +3,9 @@ from traust_core.v1.services.findings.intake import FindingIntake from traust_core.v1.services.findings.precedents import FindingPrecedents from traust_core.v1.services.findings.rating import RatingPolicy -from traust_core.v1.services.findings.review import FindingReview +from traust_core.v1.services.findings.review import FindingReview, ReviewPolicy from traust_core.v1.services.findings.service import FindingService -from traust_core.v1.services.findings.transitions import TransitionPolicy +from traust_core.v1.services.findings.taxonomy import TaxonomyPolicy __all__ = [ "FindingDecisions", @@ -15,6 +15,7 @@ "FindingService", "IdentityPolicy", "RatingPolicy", - "TransitionPolicy", + "ReviewPolicy", + "TaxonomyPolicy", "record_triage", ] diff --git a/src/traust_core/v1/services/findings/decisions.py b/src/traust_core/v1/services/findings/decisions.py index a4415112..b88e78c0 100644 --- a/src/traust_core/v1/services/findings/decisions.py +++ b/src/traust_core/v1/services/findings/decisions.py @@ -2,30 +2,47 @@ from collections.abc import Sequence from typing import Any +from uuid import UUID from traust_core.v1.models.artifacts import TriageArtifact -from traust_core.v1.models.findings import Decision, TriageVerdict -from traust_core.v1.models.operations import JobResult +from traust_core.v1.models.common import Principal, Provenance +from traust_core.v1.models.findings import ( + Disposition, + RecordDecision, + TriageInput, + TriageRecorded, + TriageVerdict, +) from traust_core.v1.models.storage import BindingContext +from traust_core.v1.models.vocabulary import RestatementReason from traust_core.v1.providers.clock import Clock from traust_core.v1.repositories.findings import FindingUnitOfWork from traust_core.v1.repositories.triage import TriageUnitOfWork -from traust_core.v1.services.findings.transitions import TransitionPolicy +from traust_core.v1.services.ledger import LedgerService from traust_core.v1.services.storage import RecordResult, bind_artifact class FindingDecisions: - def __init__(self, uow: FindingUnitOfWork, transitions: TransitionPolicy) -> None: + def __init__(self, uow: FindingUnitOfWork, ledger: LedgerService | None) -> None: self._uow = uow - self._transitions = transitions + self._ledger = ledger - def record(self, decision: Decision) -> None: - raise NotImplementedError("stub: replaces emit_*_ledger_events.py") + def decide(self, command: RecordDecision) -> Disposition: + raise NotImplementedError("stub: replaces emit_*_ledger_events.py, ledger event_handler") - def record_triage(self, triage: TriageArtifact, context: BindingContext) -> JobResult: - raise NotImplementedError( - "stub: wraps record_triage as a JobResult; replaces emit_triage_ledger_events.py" - ) + def restate( + self, decision_id: UUID, reason: RestatementReason, principal: Principal + ) -> Disposition: + raise NotImplementedError("stub: replaces ledger events/builders.build_restatement_event") + + def prepare(self, assessment_id: UUID) -> TriageInput: + raise NotImplementedError("stub: replaces the triage skill's query_index / precedent CLIs") + + def record(self, doc: TriageArtifact, provenance: Provenance) -> TriageRecorded: + raise NotImplementedError("stub: wraps record_triage; replaces emit_triage_ledger_events") + + def rebuild_dispositions(self, repository_id: UUID | None) -> int: + raise NotImplementedError("stub: replaces ledger materialize, corpus findings_db") def verdicts_from(triage: TriageArtifact, binding_id: str, digest: str) -> list[TriageVerdict]: diff --git a/src/traust_core/v1/services/findings/identity.py b/src/traust_core/v1/services/findings/identity.py index 8918a43e..3f138b1f 100644 --- a/src/traust_core/v1/services/findings/identity.py +++ b/src/traust_core/v1/services/findings/identity.py @@ -1,14 +1,18 @@ from __future__ import annotations -from traust_core.v1.models.findings import FindingCandidate -from traust_core.v1.models.integrity import Fingerprint, FingerprintInput +from collections.abc import Sequence + +from traust_core.v1.models.findings import Finding, FindingCandidate, IdentityMove +from traust_core.v1.models.integrity import Fingerprint class IdentityPolicy: def match_key(self, candidate: FindingCandidate) -> Fingerprint: raise NotImplementedError( - "stub: replaces _util/finding_identity.py, traust-ledger _internal/identity.py" + "stub: replaces _util/finding_identity.py, ledger _internal/identity.py" ) - def fingerprint(self, subject: FingerprintInput) -> Fingerprint: - raise NotImplementedError("stub: fingerprint rules pending the integrity spec (D5)") + def moves( + self, previous: Sequence[Finding], candidates: Sequence[FindingCandidate] + ) -> list[IdentityMove]: + raise NotImplementedError("stub: replaces finding_identity.identity_moves") diff --git a/src/traust_core/v1/services/findings/intake.py b/src/traust_core/v1/services/findings/intake.py index f47dba84..30226fa7 100644 --- a/src/traust_core/v1/services/findings/intake.py +++ b/src/traust_core/v1/services/findings/intake.py @@ -3,17 +3,30 @@ from collections.abc import Sequence from uuid import UUID -from traust_core.v1.models.findings import Finding, FindingCandidate +from traust_core.v1.models.common import Provenance +from traust_core.v1.models.findings import Finding, FindingCandidate, IdentityMove, IntakeResult from traust_core.v1.repositories.findings import FindingUnitOfWork from traust_core.v1.services.findings.identity import IdentityPolicy +from traust_core.v1.services.findings.taxonomy import TaxonomyPolicy class FindingIntake: - def __init__(self, uow: FindingUnitOfWork, identity: IdentityPolicy) -> None: + def __init__( + self, uow: FindingUnitOfWork, identity: IdentityPolicy, taxonomy: TaxonomyPolicy + ) -> None: self._uow = uow self._identity = identity + self._taxonomy = taxonomy - def accept(self, assessment_id: UUID, candidates: Sequence[FindingCandidate]) -> list[Finding]: + def accept( + self, + assessment_id: UUID, + candidates: Sequence[FindingCandidate], + provenance: Provenance, + ) -> IntakeResult: raise NotImplementedError( - "stub: replaces route_impact_findings.py, route_regressions.py, emit_fuzz_events.py" + "stub: replaces route_impact_findings, route_regressions, emit_fuzz_events" ) + + def rebaseline(self, moves: Sequence[IdentityMove], attestation_id: UUID) -> list[Finding]: + raise NotImplementedError("stub: replaces finding_identity.rebaseline") diff --git a/src/traust_core/v1/services/findings/precedents.py b/src/traust_core/v1/services/findings/precedents.py index aa229370..2293bbd1 100644 --- a/src/traust_core/v1/services/findings/precedents.py +++ b/src/traust_core/v1/services/findings/precedents.py @@ -1,6 +1,6 @@ from __future__ import annotations -from traust_core.v1.models.findings import Decision, FindingCandidate +from traust_core.v1.models.findings import FindingCandidate, Precedent from traust_core.v1.repositories.findings import FindingUnitOfWork @@ -8,5 +8,10 @@ class FindingPrecedents: def __init__(self, uow: FindingUnitOfWork) -> None: self._uow = uow - def match(self, candidate: FindingCandidate) -> list[Decision]: - raise NotImplementedError("stub: replaces corpus/precedent.py, compile_precedent_cards.py") + def for_candidate(self, candidate: FindingCandidate, limit: int) -> list[Precedent]: + raise NotImplementedError( + "stub: replaces corpus/precedent.py, ops/compile_precedent_cards.py" + ) + + def refresh(self) -> int: + raise NotImplementedError("stub: replaces ops/compile_precedent_cards.py") diff --git a/src/traust_core/v1/services/findings/rating.py b/src/traust_core/v1/services/findings/rating.py index 6b93e685..b3defc4f 100644 --- a/src/traust_core/v1/services/findings/rating.py +++ b/src/traust_core/v1/services/findings/rating.py @@ -1,14 +1,29 @@ from __future__ import annotations from datetime import datetime +from uuid import UUID -from traust_core.v1.models.artifacts import Severity -from traust_core.v1.models.findings import Finding +from traust_core.v1.models.assessment import Threat +from traust_core.v1.models.base import Model +from traust_core.v1.models.findings import Disposition, Occurrence, RatingInputs, RiskRating +from traust_core.v1.models.vocabulary import RiskRatingBand, Severity + + +class RatingConfig(Model): ... class RatingPolicy: - def severity(self, finding: Finding) -> Severity: # type: ignore[valid-type] - raise NotImplementedError("stub: replaces threat_rating.py, contracts risk_rating.py") + def __init__(self, config: RatingConfig) -> None: + self._config = config + + def rate(self, inputs: RatingInputs) -> RiskRating: + raise NotImplementedError("stub: replaces contracts risk_rating.py, build_trends ratings") - def sla_due(self, finding: Finding, opened_at: datetime) -> datetime: + def effective_severity(self, occurrence: Occurrence, disposition: Disposition) -> Severity: + raise NotImplementedError("stub: replaces build_trends.cvss_effective, effective_severity") + + def sla_due(self, severity: Severity, opened_at: datetime, product_id: UUID) -> datetime: raise NotImplementedError("stub: replaces metrics/sla.py") + + def threat_rating(self, threat: Threat) -> RiskRatingBand: + raise NotImplementedError("stub: replaces reporting/threat_rating.py") diff --git a/src/traust_core/v1/services/findings/review.py b/src/traust_core/v1/services/findings/review.py index 71f1ec8c..05f86923 100644 --- a/src/traust_core/v1/services/findings/review.py +++ b/src/traust_core/v1/services/findings/review.py @@ -2,15 +2,32 @@ from uuid import UUID +from traust_core.v1.models.attestation import ReviewItem +from traust_core.v1.models.common import Page, PageRequest +from traust_core.v1.models.findings import Decision, Disposition, ReviewCase, ReviewFilter +from traust_core.v1.models.vocabulary import ReviewReason, TenancyProfile from traust_core.v1.repositories.findings import FindingUnitOfWork +class ReviewPolicy: + def needs_signoff( + self, decision: Decision, disposition: Disposition, profile: TenancyProfile + ) -> ReviewReason | None: + raise NotImplementedError( + "stub: replaces emit_triage is_auto_accept, track-findings countersign rule" + ) + + class FindingReview: - def __init__(self, uow: FindingUnitOfWork) -> None: + def __init__(self, uow: FindingUnitOfWork, policy: ReviewPolicy) -> None: self._uow = uow + self._policy = policy + + def queue(self, filter: ReviewFilter, page: PageRequest) -> Page[ReviewCase]: + raise NotImplementedError("stub: replaces countersign.discover_pending") - def needs_review(self) -> list[UUID]: - raise NotImplementedError("stub: replaces countersign.py inbox derivation") + def request_signoff(self, finding_id: UUID, reason: ReviewReason) -> ReviewItem: + raise NotImplementedError("stub: replaces ledger needs_review payloads") - def apply_attestation(self, finding_id: UUID, attestation_id: UUID) -> None: - raise NotImplementedError("stub: replaces countersign.py, traust-ledger resolve_handler.py") + def apply_attestation(self, attestation_id: UUID) -> Disposition: + raise NotImplementedError("stub: replaces ledger resolve_handler, cli countersign/resolve") diff --git a/src/traust_core/v1/services/findings/service.py b/src/traust_core/v1/services/findings/service.py index 273da90b..598da952 100644 --- a/src/traust_core/v1/services/findings/service.py +++ b/src/traust_core/v1/services/findings/service.py @@ -1,21 +1,58 @@ from __future__ import annotations +from collections.abc import Sequence +from datetime import datetime +from uuid import UUID + +from traust_core.v1.models.common import Page, PageRequest, TimeBucket +from traust_core.v1.models.findings import ( + Decision, + FindingDimension, + FindingFilter, + FindingRollup, + FindingSeriesPoint, + FindingView, +) +from traust_core.v1.models.vocabulary import Severity from traust_core.v1.repositories.findings import FindingUnitOfWork from traust_core.v1.services.findings.decisions import FindingDecisions -from traust_core.v1.services.findings.identity import IdentityPolicy from traust_core.v1.services.findings.intake import FindingIntake from traust_core.v1.services.findings.precedents import FindingPrecedents -from traust_core.v1.services.findings.rating import RatingPolicy from traust_core.v1.services.findings.review import FindingReview -from traust_core.v1.services.findings.transitions import TransitionPolicy class FindingService: - def __init__(self, uow: FindingUnitOfWork) -> None: - self.identity = IdentityPolicy() - self.transitions = TransitionPolicy() - self.rating = RatingPolicy() - self.intake = FindingIntake(uow, self.identity) - self.decisions = FindingDecisions(uow, self.transitions) - self.review = FindingReview(uow) - self.precedents = FindingPrecedents(uow) + def __init__( + self, + uow: FindingUnitOfWork, + intake: FindingIntake, + decisions: FindingDecisions, + review: FindingReview, + precedents: FindingPrecedents, + ) -> None: + self._uow = uow + self.intake = intake + self.decisions = decisions + self.review = review + self.precedents = precedents + + def get(self, finding_id: UUID) -> FindingView: + raise NotImplementedError("stub: replaces ledger api/findings.resolve_layer_findings") + + def list(self, filter: FindingFilter, page: PageRequest) -> Page[FindingView]: + raise NotImplementedError("stub: replaces corpus/findings_db.py, open_findings") + + def history(self, finding_id: UUID) -> list[Decision]: + raise NotImplementedError("stub: replaces finding_timeline, layer_event reads") + + def baseline(self, repository_id: UUID, as_of: datetime) -> list[FindingView]: + raise NotImplementedError("stub: replaces vuln-scan/resolve_baseline.py, baseline_claims") + + def open_count(self, repository_id: UUID, severities: Sequence[Severity]) -> int: + raise NotImplementedError("stub: replaces traust#58 live_crit_high") + + def summary(self, filter: FindingFilter, group_by: FindingDimension) -> list[FindingRollup]: + raise NotImplementedError("stub: replaces findings_summary, pattern_exposure, dashboards") + + def timeline(self, filter: FindingFilter, bucket: TimeBucket) -> list[FindingSeriesPoint]: + raise NotImplementedError("stub: replaces build_cumulative.py, build_trends buckets") diff --git a/src/traust_core/v1/services/findings/taxonomy.py b/src/traust_core/v1/services/findings/taxonomy.py new file mode 100644 index 00000000..7cf5319f --- /dev/null +++ b/src/traust_core/v1/services/findings/taxonomy.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from traust_core.v1.models.findings import Cwe, WeaknessLabels + + +class TaxonomyPolicy: + def labels(self, cwes: Sequence[Cwe]) -> WeaknessLabels: + raise NotImplementedError( + "stub: replaces asvs_for, classify_axis, infer_techniques, ATT&CK mapping" + ) diff --git a/src/traust_core/v1/services/findings/transitions.py b/src/traust_core/v1/services/findings/transitions.py deleted file mode 100644 index 39af6e34..00000000 --- a/src/traust_core/v1/services/findings/transitions.py +++ /dev/null @@ -1,16 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from uuid import UUID - -from traust_core.v1.models.findings import Decision, Disposition - - -class TransitionPolicy: - def allow(self, current: Disposition, decision: Decision) -> None: - raise NotImplementedError("stub: replaces traust-ledger _internal/gates.py") - - def fold(self, finding_id: UUID, decisions: Sequence[Decision]) -> Disposition: - raise NotImplementedError( - "stub: replaces traust-ledger _internal/disposition.py, corpus/disposition.py" - ) diff --git a/src/traust_core/v1/services/insight.py b/src/traust_core/v1/services/insight.py new file mode 100644 index 00000000..a0450c6a --- /dev/null +++ b/src/traust_core/v1/services/insight.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import Protocol + +from traust_core.v1.models.common import Provenance +from traust_core.v1.models.insight import ViewFilter, ViewModel, ViewName +from traust_core.v1.models.operations import Order +from traust_core.v1.services.artifact_publishing import ArtifactPublisher, PublishResult +from traust_core.v1.services.operations import OpsService + + +class View(Protocol): + name: ViewName + + def build(self, filter: ViewFilter) -> ViewModel: ... + + +class InsightService: + def __init__( + self, views: Sequence[View], publisher: ArtifactPublisher, ops: OpsService + ) -> None: + self._views = {v.name: v for v in views} + self._publisher = publisher + self._ops = ops + + def build(self, view: ViewName, filter: ViewFilter) -> ViewModel: + raise NotImplementedError("stub: replaces each dashboard builder's main()") + + def publish(self, view: ViewName, filter: ViewFilter, provenance: Provenance) -> PublishResult: + raise NotImplementedError("stub: replaces dashboards' md/html/json writes") + + def refresh(self, views: Sequence[ViewName] | None) -> list[Order]: + raise NotImplementedError("stub: replaces cli/refresh_dashboards.py") + + +# Registered views (internal); each replaces the dashboard named in its stub. +class _StubView: + name: ViewName + replaces = "" + + def build(self, filter: ViewFilter) -> ViewModel: + raise NotImplementedError(f"stub: replaces {self.replaces}") + + +class FindingsRollupView(_StubView): + name, replaces = ViewName.FINDINGS_ROLLUP, "insecure-patterns, rbac-tenancy-rollup, team report" + + +class FindingsTrendView(_StubView): + name, replaces = ViewName.FINDINGS_TREND, "findings-trends" + + +class CoverageView(_StubView): + name, replaces = ViewName.COVERAGE, "census" + + +class LocView(_StubView): + name, replaces = ViewName.LOC, "loc-dashboard" + + +class AttackCoverageView(_StubView): + name, replaces = ViewName.ATTACK_COVERAGE, "attack-coverage" + + +class ThreatRegisterView(_StubView): + name, replaces = ViewName.THREAT_REGISTER, "threat-register" + + +class ExposureView(_StubView): + name, replaces = ViewName.EXPOSURE, "refresh-dashboards/build_dependency_exposure" + + +class ValidationFuzzView(_StubView): + name, replaces = ViewName.VALIDATION_FUZZ, "validation-fuzz-dashboard" + + +class PqcView(_StubView): + name, replaces = ViewName.PQC, "pqc dashboard, product reports, vendor/xcrypto trackers" + + +class ComplianceDashboardView(_StubView): + name, replaces = ViewName.COMPLIANCE, "compliance/dashboard.py" + + +class ProgramView(_StubView): + name, replaces = ViewName.PROGRAM, "traust-metrics, financial-tracking" + + +class ExecutiveSummaryView(_StubView): + name, replaces = ViewName.EXECUTIVE_SUMMARY, "executive-summary-findings" + + +class ReviewCardView(_StubView): + name, replaces = ViewName.REVIEW_CARD, "countersign render_*_card" + + +class TriageView(_StubView): + name, replaces = ViewName.TRIAGE, "triage/render_triage.py" + + +class SarifExport(_StubView): + name, replaces = ViewName.SARIF, "reporting/sarif.py" + + +class OscalExport(_StubView): + name, replaces = ViewName.OSCAL, "run_compliance_check.export_oscal" diff --git a/src/traust_core/v1/services/ledger.py b/src/traust_core/v1/services/ledger.py new file mode 100644 index 00000000..5c6330ca --- /dev/null +++ b/src/traust_core/v1/services/ledger.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from uuid import UUID + +from traust_core.v1.models.attestation import Attestation +from traust_core.v1.models.findings import Decision +from traust_core.v1.models.ledger import AppendReceipt, Checkpoint, VerifyResult +from traust_core.v1.providers.clock import Clock +from traust_core.v1.providers.ledger import LedgerClient + + +# NEED(ledger-optional): the integrity chain is optional; decisions live in DecisionRepository +# either way. When on, it always works: its tables sit in the configured record store +# (SQLite locally, Postgres on a server). No file-backed ledger, no snapshot mode. +class LedgerService: + def __init__(self, client: LedgerClient, clock: Clock) -> None: + self._client = client + self._clock = clock + + def append_decision(self, decision: Decision, owner_id: UUID) -> AppendReceipt: + raise NotImplementedError("stub: replaces emit_*_ledger_events append_to_layer") + + def append_attestation(self, attestation: Attestation, owner_id: UUID) -> AppendReceipt: + raise NotImplementedError("stub: replaces ledger cli countersign event append") + + def checkpoint(self, owner_id: UUID) -> Checkpoint: + raise NotImplementedError("stub: replaces engine ledger/service checkpoint, layer_ref") + + def verify(self, checkpoint: Checkpoint) -> VerifyResult: + raise NotImplementedError("stub: replaces build_cumulative.verify_claim_hashes") diff --git a/src/traust_core/v1/services/lint.py b/src/traust_core/v1/services/lint.py new file mode 100644 index 00000000..bdfbc592 --- /dev/null +++ b/src/traust_core/v1/services/lint.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Protocol + +from traust_core.v1.models.artifacts import Artifact +from traust_core.v1.models.base import Model + + +# Shapes land with the first Lint rule set (triage citation gate). +class LintRuleId(Model): ... + + +class LintContext(Model): ... + + +class Violation(Model): ... + + +class LintReport(Model): ... + + +class LintRule(Protocol): + id: LintRuleId + + def check(self, subject: Artifact, context: LintContext) -> list[Violation]: ... + + +class Lint: + def __init__(self, rules: Mapping[type[Artifact], Sequence[LintRule]]) -> None: + self._rules = rules + + def run(self, subject: Artifact, context: LintContext) -> LintReport: + raise NotImplementedError( + "stub: replaces check_citations, lint_verdict_citations," + " validate_compliance_assessment, validate_isolation_review," + " rbac_bucket_proposal_check, reporting/lint.py" + ) diff --git a/src/traust_core/v1/services/onboarding.py b/src/traust_core/v1/services/onboarding.py new file mode 100644 index 00000000..4c50be81 --- /dev/null +++ b/src/traust_core/v1/services/onboarding.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from traust_core.v1.models.common import Principal +from traust_core.v1.models.onboarding import OnboardingPlan, OnboardingRequest, OnboardingResult +from traust_core.v1.services.operations import OpsService +from traust_core.v1.services.portfolio import PortfolioService, PortfolioSync +from traust_core.v1.services.provider_catalog import ProviderCatalog + + +class OnboardingService: + def __init__( + self, + portfolio: PortfolioService, + sync: PortfolioSync, + providers: ProviderCatalog, + ops: OpsService, + ) -> None: + self._portfolio = portfolio + self._sync = sync + self._providers = providers + self._ops = ops + + def plan(self, request: OnboardingRequest) -> OnboardingPlan: + raise NotImplementedError("stub: replaces corpus_intake.validate_new / drift") + + def onboard(self, request: OnboardingRequest, principal: Principal) -> OnboardingResult: + raise NotImplementedError("stub: replaces corpus-intake, add-inputs skill flows") diff --git a/src/traust_core/v1/services/operations.py b/src/traust_core/v1/services/operations.py index f7556de6..4a1bfd7d 100644 --- a/src/traust_core/v1/services/operations.py +++ b/src/traust_core/v1/services/operations.py @@ -1,26 +1,47 @@ from __future__ import annotations from collections.abc import Sequence +from decimal import Decimal from typing import Protocol, TypeVar from uuid import UUID +from traust_core.v1.models.common import Page, PageRequest, Principal, TimeBucket, TimeWindow from traust_core.v1.models.operations import ( + AssetName, AssetRequest, + Budget, + BudgetVerdict, JobResult, + LaneName, Lease, Order, + OrderEvent, + OrderFilter, + OrderProgress, Refusal, Route, + SchedulePlan, + SpendAttribution, + SpendDimension, SpendEvent, + SpendFilter, + SpendRollup, + SpendSource, + StaleItem, + ThroughputPoint, + WorkerId, ) -from traust_core.v1.repositories.operations import OpsUnitOfWork +from traust_core.v1.providers.clock import Clock +from traust_core.v1.providers.feeds import FeedProvider +from traust_core.v1.repositories.operations import OpsUnitOfWork, SpendUnitOfWork +from traust_core.v1.services.provider_catalog import ProviderCatalog S = TypeVar("S", contravariant=True) E = TypeVar("E", contravariant=True) class Materializer(Protocol): - asset: str + asset: AssetName def materialize(self, request: AssetRequest) -> JobResult: ... @@ -30,23 +51,69 @@ def route(self, state: S, events: Sequence[E], refusals: Sequence[Refusal]) -> R class OpsService: - def __init__(self, uow: OpsUnitOfWork) -> None: + def __init__(self, uow: OpsUnitOfWork, clock: Clock) -> None: self._uow = uow + self._clock = clock - def submit(self, request: AssetRequest) -> Order: + def submit(self, request: AssetRequest, principal: Principal) -> Order: raise NotImplementedError("stub: replaces cron/subprocess orchestration, */ops.py facades") - def claim(self, worker: str) -> Lease | None: + def claim(self, worker: WorkerId) -> Lease | None: raise NotImplementedError("stub: order lease for the traust-engine run loop") def heartbeat(self, lease: Lease) -> Lease: raise NotImplementedError("stub: order lease renewal for the traust-engine run loop") + def save_progress(self, lease: Lease, progress: OrderProgress) -> Order: + raise NotImplementedError("stub: replaces cli/checkpoint.py skill phase state") + def complete(self, lease: Lease, result: JobResult) -> Order: raise NotImplementedError("stub: replaces per-CLI exit codes and progress files") def approve(self, order_id: UUID, attestation_id: UUID) -> Order: raise NotImplementedError("stub: replaces remediation push gates") - def record_spend(self, event: SpendEvent) -> None: - raise NotImplementedError("stub: replaces metrics/collect_spend.py, spend declarations") + def schedule(self, plan: SchedulePlan) -> list[Order]: + raise NotImplementedError("stub: replaces refresh_dashboards.build_stages") + + def get(self, order_id: UUID) -> Order: + raise NotImplementedError("stub: order read") + + def list(self, filter: OrderFilter, page: PageRequest) -> Page[Order]: + raise NotImplementedError("stub: order read") + + def events(self, order_id: UUID) -> list[OrderEvent]: + raise NotImplementedError("stub: order event read") + + def timeline(self, filter: OrderFilter, bucket: TimeBucket) -> list[ThroughputPoint]: + raise NotImplementedError("stub: replaces metrics/history.py, collect_harness_metrics") + + +class SpendService: + def __init__(self, uow: SpendUnitOfWork, budgets: Sequence[Budget]) -> None: + self._uow = uow + self._budgets = budgets + + def record(self, event: SpendEvent) -> None: + raise NotImplementedError("stub: replaces metrics/collect_spend.append_rows") + + def collect(self, source: SpendSource, window: TimeWindow) -> int: + raise NotImplementedError("stub: replaces metrics/collect_spend.py") + + def attribute(self, window: TimeWindow) -> SpendAttribution: + raise NotImplementedError("stub: replaces metrics/attribute_spend.py") + + def check_budget(self, lane: LaneName, estimate: Decimal) -> BudgetVerdict: + raise NotImplementedError("stub: replaces cli/budget_shadow.py") + + def summary(self, filter: SpendFilter, group_by: SpendDimension) -> list[SpendRollup]: + raise NotImplementedError("stub: replaces metrics/spend.py, financial-tracking") + + +class FreshnessQuery: + def __init__(self, providers: ProviderCatalog, feeds: Sequence[FeedProvider[object]]) -> None: + self._providers = providers + self._feeds = feeds + + def stale(self) -> list[StaleItem]: + raise NotImplementedError("stub: replaces cli/check_drift.py") diff --git a/src/traust_core/v1/services/portfolio.py b/src/traust_core/v1/services/portfolio.py index cfe001a1..44887f83 100644 --- a/src/traust_core/v1/services/portfolio.py +++ b/src/traust_core/v1/services/portfolio.py @@ -1,56 +1,118 @@ from __future__ import annotations -from datetime import datetime +from collections.abc import Sequence from uuid import UUID +from traust_core.v1.models.common import Page, PageRequest from traust_core.v1.models.portfolio import ( CodeLine, + Component, + ComponentFilter, + ComponentSpec, Designation, - ExposureClass, + ForgeHost, + ImageRef, + Liveness, + OrgName, + Ownership, + OwnershipSource, + Product, + ProductSlug, + RefName, + Release, Repository, - RiskTier, + RepositoryFilter, + RepoState, + Revision, + Segment, + SyncReport, + VersionLabel, ) -from traust_core.v1.models.values import HttpsRepoUrl +from traust_core.v1.models.values import GitSha, HttpsRepoUrl +from traust_core.v1.models.vocabulary import RefKind +from traust_core.v1.providers.sources import SourceProvider from traust_core.v1.repositories.portfolio import PortfolioUnitOfWork - - -class PortfolioPolicy: - def exposure_class( - self, designation: Designation | None, public: bool | None, public_upstream: bool | None - ) -> ExposureClass: - raise NotImplementedError("stub: replaces build_rescan_worklist.exposure_class") - - def risk_tier(self, open_critical_high: int, archived: bool, dormant: bool) -> RiskTier: - raise NotImplementedError("stub: replaces build_rescan_worklist.risk_tier") +from traust_core.v1.services.assessment import AssessmentService +from traust_core.v1.services.findings.service import FindingService class PortfolioService: - def __init__(self, uow: PortfolioUnitOfWork, policy: PortfolioPolicy) -> None: + def __init__(self, uow: PortfolioUnitOfWork) -> None: self._uow = uow - self._policy = policy - def register_repository(self, url: HttpsRepoUrl, product: str) -> Repository: - raise NotImplementedError("stub: replaces corpus_intake.py, inventory.py") + def register_product(self, slug: ProductSlug, segment: Segment | None) -> Product: + raise NotImplementedError("stub: replaces registry/products.py, inventory.py") - def register_code_line(self, repository_id: UUID, ref: str) -> CodeLine: + def register_repository(self, url: HttpsRepoUrl) -> Repository: + raise NotImplementedError("stub: replaces corpus_intake.py, add-inputs") + + def register_code_line(self, repository_id: UUID, ref: RefName, kind: RefKind) -> CodeLine: raise NotImplementedError("stub: replaces product_repo (product, repo, ref) rows") - def set_owner(self, repository_id: UUID, team: str) -> None: - raise NotImplementedError("stub: replaces fetch_forge_owners.py") + def register_revision(self, repository_id: UUID, sha: GitSha) -> Revision: + raise NotImplementedError("stub: replaces corpus/store_ingest repo@sha keys") - def designation(self, repository_id: UUID) -> Designation | None: - raise NotImplementedError("stub: replaces build_rescan_worklist.lookup_designation") + def add_component(self, product_id: UUID, code_line_id: UUID, spec: ComponentSpec) -> Component: + raise NotImplementedError("stub: replaces product_repo upserts") - def record_liveness( - self, repository_id: UUID, archived: bool, pushed_at: datetime | None - ) -> None: + def record_release( + self, component_id: UUID, version: VersionLabel, images: Sequence[ImageRef] + ) -> Release: + raise NotImplementedError("stub: replaces product_repo_version, build_release_events") + + def assign_ownership(self, component_id: UUID, ownership: Ownership) -> Component: + raise NotImplementedError("stub: replaces repo_owner, subject_ownership") + + def set_designation(self, repository_id: UUID, designation: Designation) -> Repository: + raise NotImplementedError("stub: replaces build_rescan_worklist.load_designations") + + def record_liveness(self, repository_id: UUID, liveness: Liveness) -> Repository: raise NotImplementedError("stub: replaces check_repo_liveness.py") - def exposure(self, repository_id: UUID) -> ExposureClass: - raise NotImplementedError("stub: replaces build_rescan_worklist.exposure_class") + def get_repository(self, repository_id: UUID) -> Repository: + raise NotImplementedError("stub: portfolio read") + + def find_repository(self, url: HttpsRepoUrl) -> Repository | None: + raise NotImplementedError("stub: replaces canon_repo / normalize_repo lookups") + + def list_repositories(self, filter: RepositoryFilter, page: PageRequest) -> Page[Repository]: + raise NotImplementedError("stub: replaces build_org_index.py, repo-graph") + + def get_product(self, product_id: UUID) -> Product: + raise NotImplementedError("stub: portfolio read") + + def find_product(self, slug: ProductSlug) -> Product | None: + raise NotImplementedError("stub: replaces registry/products.resolve_product") + + def list_components(self, filter: ComponentFilter, page: PageRequest) -> Page[Component]: + raise NotImplementedError("stub: replaces corpus/resolver ownership cuts") + + +class PortfolioSync: + def __init__(self, portfolio: PortfolioService, sources: SourceProvider) -> None: + self._portfolio = portfolio + self._sources = sources + + def refresh(self, repository_id: UUID) -> Repository: + raise NotImplementedError("stub: replaces check_repo_liveness.gh_repo_info") + + def import_org(self, org: OrgName, host: ForgeHost) -> SyncReport: + raise NotImplementedError("stub: replaces corpus_intake.py org import") + + def import_owners(self, source: OwnershipSource) -> SyncReport: + raise NotImplementedError("stub: replaces fetch_forge_owners.py") + + +class RepoStateQuery: + def __init__( + self, portfolio: PortfolioService, assessments: AssessmentService, findings: FindingService + ) -> None: + self._portfolio = portfolio + self._assessments = assessments + self._findings = findings - def risk_tier(self, repository_id: UUID, open_critical_high: int) -> RiskTier: - raise NotImplementedError("stub: replaces build_rescan_worklist.risk_tier") + def repo_state(self, repository_id: UUID) -> RepoState: + raise NotImplementedError("stub: replaces traust#58 observe_repo") - def by_org(self, org: str) -> list[Repository]: - raise NotImplementedError("stub: replaces build_org_index.py") + def list(self, filter: RepositoryFilter, page: PageRequest) -> Page[RepoState]: + raise NotImplementedError("stub: replaces build_rescan_worklist, build_crown_jewel_tiers") diff --git a/src/traust_core/v1/services/pqc.py b/src/traust_core/v1/services/pqc.py new file mode 100644 index 00000000..71b37f9b --- /dev/null +++ b/src/traust_core/v1/services/pqc.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import overload +from uuid import UUID + +from traust_core.v1.models.artifacts import PqcArtifact, PqcBlockersArtifact +from traust_core.v1.models.common import Provenance +from traust_core.v1.models.pqc import ( + PqcBlocker, + PqcDimension, + PqcFilter, + PqcReadiness, + PqcReadinessInput, + PqcRollup, +) +from traust_core.v1.repositories.assessment import PqcUnitOfWork +from traust_core.v1.services.code_facts import CodeFactsService + + +class PqcService: + def __init__(self, uow: PqcUnitOfWork, code_facts: CodeFactsService) -> None: + self._uow = uow + self._code_facts = code_facts + + def prescan(self, revision_id: UUID) -> PqcReadiness: + raise NotImplementedError("stub: replaces bulk_prescan, l2_prepass, scan_xcrypto_usage") + + def prepare(self, revision_id: UUID) -> PqcReadinessInput: + raise NotImplementedError("stub: replaces pqc-readiness skill inputs") + + @overload + def record(self, doc: PqcArtifact, provenance: Provenance) -> PqcReadiness: ... + @overload + def record(self, doc: PqcBlockersArtifact, provenance: Provenance) -> list[PqcBlocker]: ... + def record( + self, doc: PqcArtifact | PqcBlockersArtifact, provenance: Provenance + ) -> PqcReadiness | list[PqcBlocker]: + raise NotImplementedError("stub: replaces pqc_facts.py, build_pqc_blockers.py") + + def find(self, component_id: UUID) -> PqcReadiness | None: + raise NotImplementedError("stub: replaces pqc_readiness reads") + + def summary(self, filter: PqcFilter, group_by: PqcDimension) -> list[PqcRollup]: + raise NotImplementedError("stub: replaces build_pqc_rollup, pqc_posture, trackers") + + def blockers(self, filter: PqcFilter) -> list[PqcBlocker]: + raise NotImplementedError("stub: replaces build_pqc_worklist, vendor tracker") diff --git a/src/traust_core/v1/services/provider_catalog.py b/src/traust_core/v1/services/provider_catalog.py new file mode 100644 index 00000000..78b590ab --- /dev/null +++ b/src/traust_core/v1/services/provider_catalog.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from traust_core.v1.models.base import Dto +from traust_core.v1.providers.base import Readiness + + +class Capability(Dto): ... + + +class ProviderBinding(Dto): ... + + +class ToolchainStamp(Dto): ... + + +class DatabaseStatus(Dto): ... + + +class ProviderCatalog: + def resolve(self, capability: Capability) -> ProviderBinding: + raise NotImplementedError("stub: replaces registry/models.resolve, rule pack lookup") + + def readiness(self, capability: Capability | None) -> list[Readiness]: + raise NotImplementedError("stub: replaces toolchain _check/_preflight, ops/doctor.py") + + def stamp(self) -> ToolchainStamp: + raise NotImplementedError("stub: replaces toolchain _freeze/_stamp, assets.harness_version") + + def fetch_databases(self) -> list[DatabaseStatus]: + raise NotImplementedError("stub: replaces toolchain _fetch_dbs, cli/toolchain fetch-dbs") diff --git a/src/traust_core/v1/services/remediation.py b/src/traust_core/v1/services/remediation.py index 63c434bb..e6148b1a 100644 --- a/src/traust_core/v1/services/remediation.py +++ b/src/traust_core/v1/services/remediation.py @@ -1,34 +1,100 @@ from __future__ import annotations from collections.abc import Sequence +from typing import overload from uuid import UUID -from traust_core.v1.models.artifacts import VerificationArtifact -from traust_core.v1.models.operations import JobResult -from traust_core.v1.models.remediation import WorkItem -from traust_core.v1.models.storage import BindingContext +from traust_core.v1.models.artifacts import ( + FleetFixArtifact, + RemediationArtifact, + VerificationArtifact, +) +from traust_core.v1.models.common import Page, PageRequest, Provenance +from traust_core.v1.models.remediation import ( + EvidenceItem, + FleetFix, + FleetFixMatcher, + FleetTarget, + Fork, + PatchResult, + Propagation, + RemediationInput, + Verification, + VerificationFilter, + VerificationInput, + WorkItem, + WorkItemFilter, +) +from traust_core.v1.models.vocabulary import FixStrategy, RemediationStatus from traust_core.v1.repositories.remediation import RemediationUnitOfWork +from traust_core.v1.services.findings.service import FindingService class RemediationService: - def __init__(self, uow: RemediationUnitOfWork) -> None: + def __init__(self, uow: RemediationUnitOfWork, findings: FindingService) -> None: self._uow = uow + self._findings = findings - def open(self, finding_ids: Sequence[UUID]) -> WorkItem: - raise NotImplementedError("stub: replaces remediate-finding manifest scripts") + def open( + self, finding_ids: Sequence[UUID], component_id: UUID, strategy: FixStrategy | None + ) -> WorkItem: + raise NotImplementedError("stub: replaces build_remediation_manifest.build_rows") - def open_fleet_fix(self, cwe: str, repository_ids: Sequence[UUID]) -> list[WorkItem]: - raise NotImplementedError("stub: replaces fleet-fix scripts, fleet_targets.py") + def prepare(self, work_item_id: UUID) -> RemediationInput: + raise NotImplementedError("stub: replaces build_remediation_manifest.py") - def next_pending(self) -> WorkItem | None: - raise NotImplementedError("stub: replaces remediate-finding pending selection") + def resolve_fork(self, repository_id: UUID) -> Fork: + raise NotImplementedError("stub: replaces remediate-finding/fork_map.py") - def record_verification( - self, report: VerificationArtifact, context: BindingContext - ) -> JobResult: - raise NotImplementedError( - "stub: replaces emit_verification_ledger_events.py, route_regressions.py" - ) + def record_progress(self, work_item_id: UUID, status: RemediationStatus) -> WorkItem: + raise NotImplementedError("stub: replaces update_remediation_progress.py") - def propagated(self, work_item_id: UUID, code_line_id: UUID) -> bool: + def record_evidence(self, work_item_id: UUID, evidence: EvidenceItem) -> WorkItem: + raise NotImplementedError("stub: replaces property_evidence.py, mutation_evidence.py") + + @overload + def record(self, doc: RemediationArtifact, provenance: Provenance) -> WorkItem: ... + @overload + def record(self, doc: FleetFixArtifact, provenance: Provenance) -> FleetFix: ... + def record( + self, doc: RemediationArtifact | FleetFixArtifact, provenance: Provenance + ) -> WorkItem | FleetFix: + raise NotImplementedError("stub: replaces emit_remediation_report.py, fleet-fix specs") + + def fleet_targets(self, matcher: FleetFixMatcher) -> list[FleetTarget]: + raise NotImplementedError("stub: replaces fleet-fix/fleet_targets.py") + + def apply_fleet_fix( + self, fleet_fix_id: UUID, target_id: UUID, approval_id: UUID + ) -> PatchResult: + raise NotImplementedError("stub: replaces fleet-fix/apply_fleet_fix.py") + + def get(self, work_item_id: UUID) -> WorkItem: + raise NotImplementedError("stub: remediation read") + + def next_pending(self, filter: WorkItemFilter) -> WorkItem | None: + raise NotImplementedError("stub: replaces next_pending_remediation.py") + + def list(self, filter: WorkItemFilter, page: PageRequest) -> Page[WorkItem]: + raise NotImplementedError("stub: replaces emit_product_index.py, remediation_current") + + +class VerificationService: + def __init__(self, uow: RemediationUnitOfWork, findings: FindingService) -> None: + self._uow = uow + self._findings = findings + + def prepare(self, work_item_id: UUID) -> VerificationInput: + raise NotImplementedError("stub: replaces verify-remediation/build_verify_sweep.py") + + def differential(self, work_item_id: UUID, revision_id: UUID) -> EvidenceItem: + raise NotImplementedError("stub: replaces verify-remediation/scanner_differential.py") + + def propagation(self, work_item_id: UUID, component_id: UUID) -> Propagation: raise NotImplementedError("stub: replaces check_fix_propagation.py") + + def record(self, doc: VerificationArtifact, provenance: Provenance) -> Verification: + raise NotImplementedError("stub: replaces emit_verification_ledger_events.py") + + def list(self, filter: VerificationFilter, page: PageRequest) -> Page[Verification]: + raise NotImplementedError("stub: replaces verification_current") diff --git a/src/traust_core/v1/services/validation.py b/src/traust_core/v1/services/validation.py index 5b826601..1ddc7ca9 100644 --- a/src/traust_core/v1/services/validation.py +++ b/src/traust_core/v1/services/validation.py @@ -1,29 +1,78 @@ from __future__ import annotations +from collections.abc import Sequence from uuid import UUID from traust_core.v1.models.artifacts import ValidationArtifact -from traust_core.v1.models.operations import JobResult -from traust_core.v1.models.storage import BindingContext -from traust_core.v1.models.validation import ValidationAttempt, ValidationPlan +from traust_core.v1.models.common import Page, PageRequest, Principal, Provenance +from traust_core.v1.models.validation import ( + Authorization, + FindingChain, + RefusalReason, + TargetAttestation, + TargetObservation, + ValidationAttempt, + ValidationDimension, + ValidationFilter, + ValidationInput, + ValidationPlan, + ValidationRollup, + ValidationRun, + ValidationScope, +) +from traust_core.v1.models.vocabulary import ValidationTechnique +from traust_core.v1.providers.validation import ValidationAdapter from traust_core.v1.repositories.validation import ValidationUnitOfWork +from traust_core.v1.services.findings.decisions import FindingDecisions class ValidationService: - def __init__(self, uow: ValidationUnitOfWork) -> None: + def __init__( + self, + uow: ValidationUnitOfWork, + decisions: FindingDecisions, + adapters: Sequence[ValidationAdapter], + ) -> None: self._uow = uow + self._decisions = decisions + self._adapters = adapters - def plan(self, finding_id: UUID) -> ValidationPlan: - raise NotImplementedError("stub: replaces validate-findings plan.py") + def prepare(self, finding_id: UUID) -> ValidationInput: + raise NotImplementedError("stub: replaces validate-findings/ingest.py") - def authorize(self, plan_id: UUID) -> JobResult: - raise NotImplementedError("stub: replaces validation/scope.py") + def plan(self, finding_id: UUID, technique: ValidationTechnique) -> ValidationPlan: + raise NotImplementedError("stub: replaces validate-findings/plan.py, novel.py") - def attest_target(self, plan_id: UUID) -> JobResult: + def chains(self, finding_ids: Sequence[UUID]) -> list[FindingChain]: + raise NotImplementedError("stub: replaces validate-findings/chain.py") + + def authorize( + self, plan_id: UUID, scope: ValidationScope, principal: Principal + ) -> Authorization: + raise NotImplementedError("stub: replaces validation/scope.build, approval mode") + + def attest_target(self, plan_id: UUID, observed: TargetObservation) -> TargetAttestation: raise NotImplementedError("stub: replaces attest_target.py") - def record_validation(self, report: ValidationArtifact, context: BindingContext) -> JobResult: + def execute(self, plan_id: UUID, authorization: Authorization) -> ValidationRun: + raise NotImplementedError("stub: replaces validate-findings/execute.py, run.py") + + def record(self, doc: ValidationArtifact, provenance: Provenance) -> list[ValidationAttempt]: raise NotImplementedError("stub: replaces emit_validation_ledger_events.py") - def history(self, finding_id: UUID) -> list[ValidationAttempt]: - raise NotImplementedError("stub: replaces validation-audit.jsonl walks") + def refuse(self, plan_id: UUID, reason: RefusalReason) -> ValidationPlan: + raise NotImplementedError("stub: replaces validate-findings refusal records") + + def get_plan(self, plan_id: UUID) -> ValidationPlan: + raise NotImplementedError("stub: validation read") + + def attempts(self, finding_id: UUID) -> list[ValidationAttempt]: + raise NotImplementedError("stub: replaces build_remediation_manifest.latest_validation") + + def list(self, filter: ValidationFilter, page: PageRequest) -> Page[ValidationAttempt]: + raise NotImplementedError("stub: replaces validation_current") + + def summary( + self, filter: ValidationFilter, group_by: ValidationDimension + ) -> list[ValidationRollup]: + raise NotImplementedError("stub: replaces validation_exposure, validation-fuzz dashboard") diff --git a/tests/v1/conftest.py b/tests/v1/conftest.py index 29d1a205..c7337748 100644 --- a/tests/v1/conftest.py +++ b/tests/v1/conftest.py @@ -54,6 +54,12 @@ def storage_db(kind: str, tmp_path: Path) -> Generator[Engine]: eng.dispose() +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + for item in items: + if mark := item.get_closest_marker("todo"): + item.add_marker(pytest.mark.skip(reason=f"todo({mark.args[0]}): parked")) + + @pytest.fixture(params=SQL_BACKENDS) def engine(request: pytest.FixtureRequest, tmp_path: Path) -> Iterator[Engine]: with database(request.param, tmp_path) as eng: diff --git a/tests/v1/example/provider.py b/tests/v1/example/provider.py index 2bbff900..f7cdd899 100644 --- a/tests/v1/example/provider.py +++ b/tests/v1/example/provider.py @@ -5,7 +5,7 @@ from traust_core.v1.errors import TraustError from traust_core.v1.models.base import Dto -from traust_core.v1.providers.base import Provenance, Readiness, Result +from traust_core.v1.providers.base import Acquisition, Readiness, Result from traust_core.v1.providers.process import ProcessRunner @@ -29,7 +29,7 @@ def check(self) -> Readiness: def acquire(self, request: LineCountRequest) -> Result[int]: out = self._process.run(["wc", "-l", "--", str(request.path)]) count = int(out.stdout.split()[0]) - provenance = Provenance( + provenance = Acquisition( provider=self.name, acquired_at=datetime.now(UTC), source=str(request.path) ) return Result(count, provenance) diff --git a/tests/v1/models/test_artifacts.py b/tests/v1/models/test_artifacts.py index acd3df2f..e0f59ee3 100644 --- a/tests/v1/models/test_artifacts.py +++ b/tests/v1/models/test_artifacts.py @@ -5,13 +5,8 @@ from tests.v1.services.test_storage import TRIAGE from traust_core.v1 import contracts from traust_core.v1.errors import DocumentError -from traust_core.v1.models.artifacts import ( - ANALYSIS_ARTIFACTS, - Artifact, - Severity, - TriageArtifact, - Verdict, -) +from traust_core.v1.models.artifacts import ANALYSIS_ARTIFACTS, Artifact, TriageArtifact +from traust_core.v1.models.vocabulary import Severity, Verdict FINDING = { "id": "f001", diff --git a/tests/v1/models/test_base.py b/tests/v1/models/test_base.py deleted file mode 100644 index 31e803da..00000000 --- a/tests/v1/models/test_base.py +++ /dev/null @@ -1,6 +0,0 @@ -from tests.v1.stubs import assert_stub -from traust_core.v1.models.base import new_id - - -def test_new_id_is_a_stub() -> None: - assert_stub(new_id) diff --git a/tests/v1/providers/test_base.py b/tests/v1/providers/test_base.py index 876b2d76..1282af3a 100644 --- a/tests/v1/providers/test_base.py +++ b/tests/v1/providers/test_base.py @@ -34,7 +34,7 @@ def test_provider_returns_data_with_provenance(tmp_path: Path) -> None: assert provider.check().ready result = provider.acquire(LineCountRequest(path=target)) assert result.data == 3 - assert result.provenance.provider == "line-count" + assert result.acquisition.provider == "line-count" def test_provider_not_ready_when_binary_not_allowed() -> None: diff --git a/tests/v1/services/findings/test_decisions.py b/tests/v1/services/findings/test_decisions.py deleted file mode 100644 index 10bbef48..00000000 --- a/tests/v1/services/findings/test_decisions.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.findings.decisions import FindingDecisions - -SERVICE = FindingDecisions(None, None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/findings/test_identity.py b/tests/v1/services/findings/test_identity.py deleted file mode 100644 index 9bdf5514..00000000 --- a/tests/v1/services/findings/test_identity.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.findings.identity import IdentityPolicy - - -@pytest.mark.parametrize("method", public_methods(IdentityPolicy()), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/findings/test_intake.py b/tests/v1/services/findings/test_intake.py deleted file mode 100644 index b5373e70..00000000 --- a/tests/v1/services/findings/test_intake.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.findings.intake import FindingIntake - -SERVICE = FindingIntake(None, None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/findings/test_precedents.py b/tests/v1/services/findings/test_precedents.py deleted file mode 100644 index 6cf16375..00000000 --- a/tests/v1/services/findings/test_precedents.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.findings.precedents import FindingPrecedents - -SERVICE = FindingPrecedents(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/findings/test_rating.py b/tests/v1/services/findings/test_rating.py deleted file mode 100644 index f433439e..00000000 --- a/tests/v1/services/findings/test_rating.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.findings.rating import RatingPolicy - - -@pytest.mark.parametrize("method", public_methods(RatingPolicy()), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/findings/test_review.py b/tests/v1/services/findings/test_review.py deleted file mode 100644 index d54304be..00000000 --- a/tests/v1/services/findings/test_review.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.findings.review import FindingReview - -SERVICE = FindingReview(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/findings/test_service.py b/tests/v1/services/findings/test_service.py deleted file mode 100644 index 2f19c822..00000000 --- a/tests/v1/services/findings/test_service.py +++ /dev/null @@ -1,23 +0,0 @@ -from traust_core.v1.services.findings import ( - FindingDecisions, - FindingIntake, - FindingPrecedents, - FindingReview, - FindingService, - IdentityPolicy, - RatingPolicy, - TransitionPolicy, -) - - -def test_finding_service_wires_its_parts_and_policies() -> None: - findings = FindingService(None) # type: ignore[arg-type] - assert isinstance(findings.intake, FindingIntake) - assert isinstance(findings.decisions, FindingDecisions) - assert isinstance(findings.review, FindingReview) - assert isinstance(findings.precedents, FindingPrecedents) - assert isinstance(findings.identity, IdentityPolicy) - assert isinstance(findings.transitions, TransitionPolicy) - assert isinstance(findings.rating, RatingPolicy) - assert findings.intake._identity is findings.identity - assert findings.decisions._transitions is findings.transitions diff --git a/tests/v1/services/findings/test_transitions.py b/tests/v1/services/findings/test_transitions.py deleted file mode 100644 index 5eadb192..00000000 --- a/tests/v1/services/findings/test_transitions.py +++ /dev/null @@ -1,9 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.findings.transitions import TransitionPolicy - - -@pytest.mark.parametrize("method", public_methods(TransitionPolicy()), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_assessment.py b/tests/v1/services/test_assessment.py deleted file mode 100644 index e268e71c..00000000 --- a/tests/v1/services/test_assessment.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.assessment import AssessmentService - -SERVICE = AssessmentService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_attestation.py b/tests/v1/services/test_attestation.py deleted file mode 100644 index 484a7562..00000000 --- a/tests/v1/services/test_attestation.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.attestation import AttestationService - -SERVICE = AttestationService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_code_facts.py b/tests/v1/services/test_code_facts.py deleted file mode 100644 index 405f1292..00000000 --- a/tests/v1/services/test_code_facts.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.code_facts import CodeFactsService - -SERVICE = CodeFactsService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_compliance.py b/tests/v1/services/test_compliance.py deleted file mode 100644 index 0d7b35e5..00000000 --- a/tests/v1/services/test_compliance.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.compliance import ComplianceService - -SERVICE = ComplianceService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_detection_quality.py b/tests/v1/services/test_detection_quality.py deleted file mode 100644 index 3f64a0f0..00000000 --- a/tests/v1/services/test_detection_quality.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.detection_quality import DetectionQualityService - -SERVICE = DetectionQualityService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_exposure.py b/tests/v1/services/test_exposure.py deleted file mode 100644 index 35a1ea6f..00000000 --- a/tests/v1/services/test_exposure.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.exposure import ExposureService - -SERVICE = ExposureService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_operations.py b/tests/v1/services/test_operations.py deleted file mode 100644 index f14a3b3c..00000000 --- a/tests/v1/services/test_operations.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.operations import OpsService - -SERVICE = OpsService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_portfolio.py b/tests/v1/services/test_portfolio.py deleted file mode 100644 index eebac4c8..00000000 --- a/tests/v1/services/test_portfolio.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.portfolio import PortfolioPolicy, PortfolioService - -SERVICE = PortfolioService(None, PortfolioPolicy()) # type: ignore[arg-type] -POLICY = PortfolioPolicy() - - -@pytest.mark.parametrize( - "method", public_methods(SERVICE) + public_methods(POLICY), ids=lambda m: m.__qualname__ -) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_remediation.py b/tests/v1/services/test_remediation.py deleted file mode 100644 index 215070ce..00000000 --- a/tests/v1/services/test_remediation.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.remediation import RemediationService - -SERVICE = RemediationService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/services/test_validation.py b/tests/v1/services/test_validation.py deleted file mode 100644 index 276ea43f..00000000 --- a/tests/v1/services/test_validation.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from tests.v1.stubs import assert_stub, public_methods -from traust_core.v1.services.validation import ValidationService - -SERVICE = ValidationService(None) # type: ignore[arg-type] - - -@pytest.mark.parametrize("method", public_methods(SERVICE), ids=lambda m: m.__name__) -def test_method_is_a_stub(method) -> None: - assert_stub(method) diff --git a/tests/v1/stubs.py b/tests/v1/stubs.py deleted file mode 100644 index ea6e48e9..00000000 --- a/tests/v1/stubs.py +++ /dev/null @@ -1,18 +0,0 @@ -import inspect -from collections.abc import Callable -from typing import Any - -import pytest - - -def public_methods(obj: object) -> list[Callable[..., Any]]: - return [ - getattr(obj, name) - for name, _ in inspect.getmembers(type(obj), inspect.isfunction) - if not name.startswith("_") - ] - - -def assert_stub(fn: Callable[..., Any]) -> None: - with pytest.raises(NotImplementedError, match="stub"): - fn(*[None] * len(inspect.signature(fn).parameters)) diff --git a/tests/v1/test_example_continuous_ops.py b/tests/v1/test_example_continuous_ops.py index b8595112..b4c19ed5 100644 --- a/tests/v1/test_example_continuous_ops.py +++ b/tests/v1/test_example_continuous_ops.py @@ -19,7 +19,7 @@ from traust_core.v1.errors import ServiceError from traust_core.v1.models.operations import AssetRequest, Outcome from traust_core.v1.models.values import GitSha, HttpsRepoUrl -from traust_core.v1.providers.base import Provenance, Readiness, Result +from traust_core.v1.providers.base import Acquisition, Readiness, Result from traust_core.v1.providers.sources import RepoInfo from traust_core.v1.repositories import create_database_engine @@ -42,7 +42,7 @@ def info(self, repo: HttpsRepoUrl) -> Result[RepoInfo]: if str(repo) not in self._heads: raise ServiceError(f"{repo}: unreachable") info = RepoInfo(repo=repo, head=GitSha(self._heads[str(repo)])) - return Result(info, Provenance(provider=self.name, acquired_at=CLOCK.now())) + return Result(info, Acquisition(provider=self.name, acquired_at=CLOCK.now())) def checkout(self, request: object) -> Result[object]: raise NotImplementedError diff --git a/tests/v1/test_stubs.py b/tests/v1/test_stubs.py new file mode 100644 index 00000000..d59c30e0 --- /dev/null +++ b/tests/v1/test_stubs.py @@ -0,0 +1,91 @@ +import ast +import importlib +import inspect +import pkgutil +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import Any + +import pytest + +import traust_core.v1 as v1 + + +def _is_stub(node: ast.FunctionDef) -> bool: + body = [n for n in node.body if not isinstance(n, ast.Expr)] + if len(body) != 1 or not isinstance(body[0], ast.Raise): + return False + exc = body[0].exc + return ( + isinstance(exc, ast.Call) + and getattr(exc.func, "id", None) == "NotImplementedError" + and bool(exc.args) + and "stub" in ast.unparse(exc.args[0]) + ) + + +def _stubs() -> Iterator[tuple[str, Callable[..., Any]]]: + for info in pkgutil.walk_packages(v1.__path__, "traust_core.v1."): + module = importlib.import_module(info.name) + tree = ast.parse(Path(inspect.getfile(module)).read_text()) + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and _is_stub(node) and node.col_offset == 0: + yield info.name, getattr(module, node.name) + for cls_node in (n for n in tree.body if isinstance(n, ast.ClassDef)): + cls = getattr(module, cls_node.name) + for fn in (n for n in cls_node.body if isinstance(n, ast.FunctionDef)): + if _is_stub(fn): + yield f"{info.name}.{cls_node.name}", inspect.getattr_static(cls, fn.name) + + +STUBS = sorted(_stubs(), key=lambda s: (s[0], getattr(s[1], "__qualname__", ""))) + + +def _callable(owner: str, member: Any) -> Callable[..., Any]: + if isinstance(member, classmethod | staticmethod): + return member.__get__(None, _owner_class(owner)) + if inspect.isclass(owner_obj := _resolve(owner)): + return member.__get__(object.__new__(owner_obj), owner_obj) + return member + + +def _resolve(owner: str) -> Any: + try: + return importlib.import_module(owner) + except ModuleNotFoundError: + return _owner_class(owner) + + +def _owner_class(owner: str) -> type: + module, _, name = owner.rpartition(".") + return getattr(importlib.import_module(module), name) + + +def _id(stub: tuple[str, Any]) -> str: + fn = stub[1].__func__ if isinstance(stub[1], classmethod | staticmethod) else stub[1] + return f"{stub[0].removeprefix('traust_core.v1.')}::{fn.__name__}" + + +@pytest.mark.parametrize("stub", STUBS, ids=_id) +def test_stub_raises_not_implemented(stub: tuple[str, Any]) -> None: + owner, member = stub + fn = _callable(owner, member) + params = list(inspect.signature(fn).parameters) + with pytest.raises(NotImplementedError, match="stub"): + fn(*[None] * len(params)) + + +@pytest.mark.parametrize("stub", STUBS, ids=_id) +def test_stub_signature_is_fully_typed(stub: tuple[str, Any]) -> None: + member = stub[1] + fn = member.__func__ if isinstance(member, classmethod | staticmethod) else member + sig = inspect.signature(fn) + params = [p for p in sig.parameters.values() if p.name not in ("self", "cls")] + untyped = [p for p in params if p.annotation is p.empty] + assert not untyped, f"untyped parameters: {untyped}" + assert sig.return_annotation is not sig.empty, "missing return annotation" + + +def test_every_domain_has_stubs() -> None: + roles = {owner.split(".")[2] for owner, _ in STUBS} + assert {"models", "repositories", "services", "context"} <= roles