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
35 changes: 17 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
name: Release

# Tag push publishes. The human gate is the `pypi` environment below, which pauses the
# run BEFORE the OIDC token is minted — so the approval exists regardless of what
# triggered the workflow, and a tag pushed by accident cannot reach PyPI unattended.
# Publishing a GitHub Release is the gate. Not a tag push: a tag is cheap to create by
# accident and impossible to take back once it has reached PyPI, whereas a Release is a
# deliberate act with the notes in front of you.
#
# The `pypi` environment below is the second gate, and the stronger one — it pauses the
# run BEFORE the OIDC token is minted. Environment protection rules are unavailable on a
# private repo under this plan, so it currently holds none; add a required reviewer once
# the repo is public and this becomes belt-and-braces.
on:
push:
tags: ['v*']
release:
types: [published]
workflow_dispatch:

permissions:
Expand Down Expand Up @@ -55,12 +60,12 @@ jobs:
# sigstore signing job would be duplicative.
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2

# Its own job so the publish job keeps `id-token: write` as its ONLY permission. This
# one needs `contents: write` to create the release, and has no business holding the
# publishing identity while it does.
github-release:
# Attaching the built artifacts to the Release that triggered this. Its own job so the
# publish job keeps `id-token: write` as its ONLY permission — `contents: write` has no
# business sitting alongside the publishing identity.
attach-artifacts:
needs: [publish]
if: startsWith(github.ref, 'refs/tags/')
if: github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
contents: write
Expand All @@ -69,13 +74,7 @@ jobs:
with:
name: dist
path: dist/
# --generate-notes builds the body from merged PRs, categorised by .github/release.yml.
# Release notes are therefore a side effect of labelling PRs rather than a file
# anyone has to remember to update.
- env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" dist/* \
--repo "${GITHUB_REPOSITORY}" \
--title "${GITHUB_REF_NAME}" \
--generate-notes
TAG: ${{ github.event.release.tag_name }}
run: gh release upload "$TAG" dist/* --repo "${GITHUB_REPOSITORY}" --clobber
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ __pycache__/
dist/
build/
.venv/

# The diagram generator is kept locally, not checked in.
docs/img/generate.py
63 changes: 63 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# AGENTS.md

Instructions for coding agents working in this repository.
Human contributors want [CONTRIBUTING.md](CONTRIBUTING.md); this file only covers the
constraints that are invisible from the code and expensive to violate.

## Checks

```bash
pip install pytest && python -m pytest tests/ -q
ruff check . && ruff format --check .
```

Lint runs over the whole repository, markdown included, because ruff formats fenced
Python inside it. Scoping a local run to `postflight/` and `tests/` is how local and CI
end up disagreeing.

## Constraints

**No dependencies. Ever.** `dependencies = []` is the reason this package can be added to
anything without a version negotiation, and a test enforces it. The HTTP clients use
`urllib` rather than `httpx` on purpose. An adapter parses exported JSON rather than
importing a vendor SDK: the producer needs the SDK, the reader does not.

**Detector codes are the public API.** `UNVERIFIED_CLAIM`, `TOOL_REFUSAL` and the rest
are what callers filter, chart and page on. Renaming one is a breaking change. Adding a
detector is not.

**Unknown is not zero.** `cache_read_tokens=None` means the producer does not report
cache usage; `0` means it reported none. Detectors must skip the first rather than treat
absence as evidence. The same shape recurs elsewhere: never infer a problem from a
missing signal.

**Kind-keyed rules are deny-lists, never allow-lists.** `Turn.kind` falls back to
`"unknown"` when an adapter cannot resolve it, so an allow-list silently exempts real
turns and every future surface. Failing closed is the point.

**Detectors are mechanism; vocabulary is configuration.** Tool names, claim phrasings,
thresholds and surface names belong in `Config`, supplied by the caller. If a change
teaches the package something about a particular product or domain, it is in the wrong
place.

**A detector that fires on healthy behaviour is worse than one that misses.** Every
narrowing in `detectors.py` exists because something cried wolf. Before widening a rule,
add the case it must NOT fire on. Both directions get a test.

## Writing

Comments explain **why the code is the way it is**, not what it does and not the story of
how the problem was found. Incident narratives, measured percentages and production
counts do not belong here.

Public copy (README, `docs/`, `CONTRIBUTING`, `SECURITY`, and anything the CLI prints)
uses no em-dashes. Rewrite the sentence rather than swapping in a comma, which produces
splices.

Release notes live on the Releases page and are generated from merged PR titles. There is
no changelog file; do not add one.

## Do not touch

`docs/img/*.svg` are generated, with text converted to outlines. They are path data, not
editable markup, and the generator is not in this repository. Leave them alone.
37 changes: 33 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Contributing

<br>

## Running it

```bash
Expand All @@ -10,38 +12,65 @@ python -m pytest tests/ -q
```

There is no install step and no dependency file to sync. If `pip install pytest` is not
enough to run the suite, that is a bug in this project, not in your setup — the package
enough to run the suite, that is a bug in this project, not in your setup. The package
is zero-dependency and CI proves it by installing nothing else.

<br>

## What a good change looks like

**A new detector** needs a name that describes the *behaviour*, not a metric, and a test
for both directions the case it must catch and the case it must not. The second is the
for both directions: the case it must catch and the case it must not. The second is the
one that matters: a detector that fires on healthy traffic is how the real findings get
ignored, and every detector here was narrowed at least once because of that.

**A new adapter** is a function from your trace format to `Turn`. Read "Writing an
adapter" in the README first; the four notes there are each a mistake already made once.
Adapters must not add a dependency — parse exported JSON rather than importing a vendor
Adapters must not add a dependency. Parse exported JSON rather than importing a vendor
SDK.

**Detector codes are the public interface.** Renaming one is a breaking change, so it
lands as a minor bump with a changelog entry, not quietly.

<br>

## Tuning vs mechanism

The line this project is organised around: detectors are mechanism and live in code;
thresholds, tool-name conventions and claim vocabulary are tuning and live in `Config`.
If a change makes postflight know something about *your* domain, it probably belongs in
your `Config`, not in here. Say so in the PR if you think it's the exception.

<br>

## Before you open a PR

- `python -m pytest tests/ -q` passes
- New behaviour has a test
- A comment explains *why the code is the way it is*, where that is not obvious. Not what
it does, and not the story of how it was found
- The PR title reads as a release note it becomes one verbatim, since release notes are
- The PR title reads as a release note, because it becomes one verbatim: release notes are
generated from merged PRs at tag time

If you are working through a coding agent, point it at [AGENTS.md](AGENTS.md), which carries the constraints that are invisible from the code.

Issues and PRs are welcome. There is no response SLA.

<br>

## Releasing

`main` requires a pull request and green checks; direct pushes are blocked for everyone
without an admin bypass.

1. Merge everything you want in the release, with PR titles that read as release notes.
they become the notes verbatim, categorised by label via `.github/release.yml`.
2. Bump `version` in `pyproject.toml` and `__version__` in `postflight/__init__.py`
(a test fails if they disagree), via a PR like any other change.
3. Tag it: `git tag v0.1.0 && git push origin v0.1.0`. A tag alone publishes nothing.
4. Create the GitHub Release for that tag with generated notes. **Publishing the Release
is what triggers the PyPI upload.** A tag is easy to push by accident and impossible
to retract once it has reached PyPI, so the deliberate act is the gate.

The workflow builds, checks the metadata, publishes via Trusted Publishing (no API token
exists), and attaches the artifacts back to the Release.
Loading