Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .agents/skills/python-patterns/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 `<entity>_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 `<entity>_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?
65 changes: 65 additions & 0 deletions .agents/skills/python-patterns/references/idioms.py
Original file line number Diff line number Diff line change
@@ -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
11 changes: 6 additions & 5 deletions .agents/skills/traust-core-patterns/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
---
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/` |
| A report | [report.md](report.md) | `tests/v1/example/report.py` |
| 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`.
2 changes: 1 addition & 1 deletion .agents/skills/traust-core-patterns/provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -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("<name>", lambda: Provider(ctx.process))`.
8. Generic providers (git, forges) live in core; security tools and feeds live in the consuming package.
Expand Down
20 changes: 12 additions & 8 deletions .agents/skills/traust-core-patterns/repository.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Repository

1. Model in `models/<subject>.py`: `Entity`/`Model` subclass or frozen dataclass. No database types.
2. Interface: a `Protocol` with only the methods callers need (`add`, `get`, `list_by_<x>`, ...). No generic CRUD.
2. Interface: a `Protocol` over one aggregate, in `repositories/<subject>.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 `<entity>_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)
Expand All @@ -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/<subject>.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.
Expand Down
30 changes: 17 additions & 13 deletions .agents/skills/traust-core-patterns/service.md
Original file line number Diff line number Diff line change
@@ -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/<area>.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/<subject>.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(...) -> <X>Input` (extends `SkillInput`) and `record(doc: <X>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/<subject>.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/<subject>.py`; the `Protocol` in the service module that consumes it.
2. Implemented in the owning repo; export from `services/__init__.py`; README row.

## Config

Expand All @@ -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).
13 changes: 0 additions & 13 deletions .agents/skills/traust-core-patterns/versioning.md

This file was deleted.

9 changes: 9 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 0 additions & 41 deletions .github/workflows/release.yml

This file was deleted.

Loading
Loading