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
67 changes: 67 additions & 0 deletions .claude/skills/regenerate-bindings/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
name: regenerate-bindings
description: Regenerate src/saas_sdk/_gen from the accounts proto in module-saas-starter — running scripts/generate.sh, the option-stripping step that keeps shared descriptors out of the global pool, and the SOURCE.txt record. Use when the accounts proto changed, when a new message or proto file has to be exposed, or when a review asks why _gen looks different from a plain buf generate.
---

# Regenerating the protobuf bindings

`src/saas_sdk/_gen` is generated, never hand-edited. An edit there is silently
reverted by the next regeneration and is not a fix.

## Source of truth

The protos live in `module-saas-starter` at
`module/services/accounts/proto`. Two files are exposed by this SDK:
`saas/accounts/v1/datasource.proto` and `saas/accounts/v1/work_contexts.proto`
(the list is `protos=()` in `scripts/generate.sh`).

The commit those bindings came from is recorded in `SOURCE.txt`. Read it before
regenerating: if the proto change you need is not yet merged in
`module-saas-starter`, that merge is the first pull request, not a local
checkout regenerated against an unmerged branch.

## The walk

From a checkout of `module-saas-starter`:

```bash
scripts/generate.sh <module-saas-starter>/module/services/accounts/proto
```

Requires `buf` and a `python3` with the `protobuf` package importable (the
script calls `scripts/strip_options.py` with plain `python3`, not the project
venv — activate the venv if the system interpreter lacks `protobuf`).

Then, in the same pull request:

1. Update `SOURCE.txt` — the `module-saas-starter` commit SHA, and the module
version if it moved. That version is also this SDK's release version in
`pyproject.toml`; the two are matched on purpose.
2. Run the suite: `python -m pytest -q`.
3. If a new proto file is exposed, add it to `protos=()` in
`scripts/generate.sh`, to the service list in `SOURCE.txt`, and to the
package description in `README.md`.

## Why generation is not plain `buf generate`

The script builds a descriptor image, then `scripts/strip_options.py` removes
the custom options (`buf.validate`, `saas.policy`) and the `google.api` HTTP
annotations together with their shared-proto dependencies, and only then
generates. Without that step the bindings embed shared descriptors and register
them into the **global descriptor pool**, where they collide at import time with
any sibling saas SDK that did the same. The well-known `google.protobuf` types
are deliberately left as runtime types.

Message bindings only: `buf.gen.yaml` generates no client or server stubs,
because the solution runtime owns the Connect transport and this SDK calls
through `Gateway.unary`. Do not add a grpc plugin to get a stub — if a consumer
needs a call this SDK does not expose, the facade
(`src/saas_sdk/datasource.py`) grows a method that names the procedure.

## Version floors

`buf.gen.yaml` pins both plugins to `v29.3`, which is the gencode line matching
the `protobuf>=5.29.3` floor in `pyproject.toml`. Bumping the plugin without the
floor produces bindings that fail to import for consumers on protobuf 5.x;
bumping the floor without the plugin is an unmotivated break of the same
consumers. They move in one change or not at all.
66 changes: 66 additions & 0 deletions .claude/skills/wire-format-parity/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
name: wire-format-parity
description: Change anything covered by a fixture shared byte-for-byte with saas-sdk-go — the Work Context token encoding (tests/fixtures/work_context_wire_golden.json) or the authorization-revision projection (tests/fixtures/authorization_revision_projection.json). Use when a golden test fails, when a claim, scope or payload field is added or renamed, or before editing either fixture for any reason.
---

# Changing something the Go SDK also implements

Two fixtures in `tests/fixtures/` are byte-identical to files in
[`saas-sdk-go`](https://github.com/codefly-dev/saas-sdk-go), and CI in both
repos asserts against the same bytes:

| here | there |
| --- | --- |
| `work_context_wire_golden.json` | `TestWorkContextWireGolden` |
| `authorization_revision_projection.json` | `authorizationrevision/testdata/projection.json` |

They exist so the two implementations cannot drift without a test going red.
That makes a red golden test **informative**, not an obstacle.

## If a golden test fails

Do not touch the fixture first. The failure means this implementation no longer
produces (or accepts) the bytes the Go one does, and exactly one of the two is
wrong.

1. Find which side moved. `git log -- tests/fixtures/` and the recent diff in
`src/saas_sdk/work_context.py` usually answer it in one read.
2. If this SDK moved, fix the code here. The encoding is pinned in detail —
field order, `uint64` carried as a decimal *string*, scope canonicalization,
base64url without padding, `key_id` *inside* the payload rather than in a
header. The token is **not** a JWT; do not reach for a JWT library to
"normalise" it.
3. If the Go SDK moved, the fix belongs there, and this is a cross-repo change:
one pull request in each repo, each naming the other, landing together.

Editing the fixture so the local suite goes green, without the matching change
in `saas-sdk-go`, is a hack in the sense the root `AGENTS.md` means: it removes
the only signal that the two implementations disagree, and the disagreement
survives in production, where one side mints what the other rejects.

## Adding a field to the wire format

The payload is closed: `_reject_unknown` in `src/saas_sdk/work_context.py`
rejects any key the verifier does not know, so a field added on the Go side and
not here is not ignored — it is a hard verification failure for every token that
carries it. Sequence it deliberately: verifier support ships and is deployed
before anything mints the new field.

A new field needs, in the same change: parsing in `_unmarshal`, structural
validation in `_validate_work_context` (including the proto's `min_len`
constraints for optional fields), the mint side if a caller sets it, and the
regenerated `work_contexts_pb2` if it is new in the proto (see
`regenerate-bindings`).

## Regenerating a fixture

Only after the Go side is agreed. Copy the file from `saas-sdk-go` verbatim
rather than re-emitting it here — the point of the fixture is that the bytes
came from the other implementation. Then run `python -m pytest -q` and confirm
the golden test verifies the copied token against the copied public key;
`test_wire_golden_payload_matches_fixture_claims_field_by_field` checks the
decoded payload field by field, so a fixture that merely parses still fails if a
claim changed meaning.

Say in the pull request body which Go commit the fixture was taken from, and
whether the paired Go pull request has landed.
127 changes: 127 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# saas-sdk-python — agent instructions

This repo is the **Python SDK for the saas accounts API**, the twin of
[`saas-sdk-go`](https://github.com/codefly-dev/saas-sdk-go): a versioned,
published client that solutions depend on instead of regenerating their own
bindings. `README.md` documents the SDK for the people consuming it; this file
is how to work *in* the repo.

## What this repo owns — and does not

- **Owns:** the generated message bindings (`saas_sdk._gen`); the gateway-bound
`datasource` facade; the consumer-side halves of Work Context — the mint
client and the verifier (`saas_sdk.work_context`); the current-revision client
(`saas_sdk.authorization_revision`).
- **Does not own:** the protos, which live in `module-saas-starter`
(`module/services/accounts/proto`); the Connect transport, which the solution
runtime owns behind `Gateway.unary`; the Work Context *signer*, which stays
authority-side in `sdk-go`; the wire format, which is pinned to `sdk-go`.
- **Never hand-edit `src/saas_sdk/_gen/`.** It is regenerated from a proto ref
recorded in `SOURCE.txt` — see the `regenerate-bindings` skill.
- Everything ships under the single top-level package `saas_sdk`, so installing
it never claims a generic name in a consumer's import namespace. A new module
goes inside that package, never beside it.
- The runtime takes **no** dependency on this SDK. If a change here would
require one, the change is in the wrong repo.

## Build and test

Transcribed from `.github/workflows/ci.yml`, which runs the matrix Python
**3.10** and **3.12** on every pull request:

```bash
python -m pip install --upgrade pip
pip install -e ".[test,revision-connect,revision-grpc]"
python -m pytest -q
```

Both networking extras are installed deliberately. `revision-connect` (httpx)
and `revision-grpc` (grpcio) are optional for consumers but not for the suite:
the tests import `grpc` and `httpx` at module scope, so a missing extra fails
collection loudly instead of quietly skipping the transport tests. There are no
`skipif` markers in `tests/` — keep it that way.

Two floors move together and are not independently adjustable:
`requires-python = ">=3.10"` in `pyproject.toml`, and `protobuf>=5.29.3`, which
is the gencode line the bindings are generated with (`buf.gen.yaml` pins the
plugins to `v29.3`). Raising one without the other makes the published SDK
uninstallable for consumers still on protobuf 5.x.

## Parity with saas-sdk-go is a cross-repo invariant

Two fixtures are byte-identical to their Go counterparts, and CI in both repos
verifies the same bytes:

- `tests/fixtures/work_context_wire_golden.json` ↔ sdk-go's
`TestWorkContextWireGolden`. The wire format is **not** a JWT; field order,
`uint64`-as-decimal-string encoding, scope canonicalization, base64url and
Ed25519 signing are pinned byte-for-byte.
- `tests/fixtures/authorization_revision_projection.json` ↔ the Go SDK's
`authorizationrevision/testdata/projection.json`.

Editing a fixture to make a test here pass is not a fix — it is a silent break
of the other implementation, which will keep minting or verifying the old bytes.
A wire change is one coordinated pair of pull requests. See the
`wire-format-parity` skill.

## When something does not work

These are the fleet's rules, not this repo's, and they are stated here because
an agent reads this file and not the tracker:
https://github.com/obin-ai/handbook/issues/68.

- **A gap in the tooling is a bug in the tooling** — never a reason to reach
around it. Not as a "workaround", not "just this once", not "until the verb
lands". A missing capability in `codefly`, `buf` or the generation scripts is
an issue against the thing that owns it.
- **Never hack. Always provide the best fix, even when it spans repos.** The
right fix living in `module-saas-starter`, `sdk-go` or the solution runtime is
not a reason to work around it here — open the pull request there. When it
genuinely cannot be fixed now, the deliverable is a precise issue against the
owner plus a stopgap that says in the code and in the pull request body that
it is one. Never an unlabelled one.
- **Classify every change that makes something work**, in the pull request body:
a *fix* at the place that owns the behaviour, or a *hack*. A hack does not
become a fix by working, by being small, by being local, or by the real fix
belonging elsewhere.
- **Never hardcode what the system resolves.** This SDK exists to be the thing
that resolves. `datasource.new(gateway)` routes through the runtime's
`Gateway.unary` rather than typing an endpoint; `JWKSVerifier` discovers
signing keys through the published JWKS rather than pinning a key; the
revision client loads its credential through a caller-supplied async callback
rather than reading one out of another component's config. A literal endpoint,
token, port or key in this package is a defect, and a test fixture that needs
one builds it locally.
- **Diagnose, do not pattern-match.** "It started working when I set X" is not a
diagnosis — set X back and confirm it breaks. Do not trust an error message
before checking that it says what it appears to say. Nearly everything in
`work_context` raises the one `WorkContextError` type, so the type discriminates
nothing and only the message does; and `JWKSVerifier` refreshes at most once per
cache generation on an unknown `key_id`, so a token that fails and then passes on
a retry has told you about key rotation, not about flakiness.
- **A silent skip is a failed test.** Nothing here may pass by not running.
Never guard a test on an optional extra being importable — install the extra
(CI does) so the test runs or the suite fails.
- **Say what you did not verify.** Unverified is not the same as working. The
matrix is 3.10 and 3.12; if you ran one interpreter, or could not exercise a
transport, the pull request body says so in as many words.

## Procedures

Step-by-step procedures live in `.claude/skills/`, read only when the task needs
them rather than carried here:

<!-- skills -->
- `regenerate-bindings` — regenerating `src/saas_sdk/_gen` from the accounts
proto, including the option-stripping step and `SOURCE.txt`.
- `wire-format-parity` — changing anything covered by a fixture shared
byte-for-byte with `saas-sdk-go`.
<!-- /skills -->

`tests/test_agent_context.py` holds that list to the directories that exist, so
a renamed or deleted skill cannot leave a pointer here aimed at nothing. It also
holds this file to its length budget.

Keep this file under ~200 lines. Context an agent needs only in one subtree
belongs in a nested `AGENTS.md` beside that subtree, and a procedure belongs in
a skill — never appended here.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
66 changes: 66 additions & 0 deletions tests/test_agent_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Agent context files hold their shape: the root file stays short, CLAUDE.md
stays a pointer to it, and the skill list cannot drift from the directories."""

import re
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parents[1]
AGENTS = ROOT / "AGENTS.md"
SKILLS = ROOT / ".claude" / "skills"

MAX_AGENTS_LINES = 200
MAX_SKILL_BODY_LINES = 500
MAX_DESCRIPTION_CHARS = 1024
NAME_PATTERN = re.compile(r"^[a-z0-9-]{1,64}$")


def skill_files():
return sorted(SKILLS.glob("*/SKILL.md"))


def parse_frontmatter(path):
lines = path.read_text().splitlines()
assert lines and lines[0] == "---", f"{path} does not open with frontmatter"
end = lines.index("---", 1)
fields = {}
for line in lines[1:end]:
key, _, value = line.partition(":")
fields[key.strip()] = value.strip()
return fields, lines[end + 1 :]


def test_agents_file_stays_within_its_length_budget():
lines = AGENTS.read_text().splitlines()
assert len(lines) <= MAX_AGENTS_LINES


def test_claude_md_is_only_a_pointer_to_agents_md():
assert (ROOT / "CLAUDE.md").read_text().strip() == "@AGENTS.md"


def test_listed_skills_are_exactly_the_skills_that_exist():
listed = set(
re.findall(
r"^- `([a-z0-9-]+)`",
AGENTS.read_text().split("<!-- skills -->")[1].split("<!-- /skills -->")[0],
re.MULTILINE,
)
)
assert listed
assert listed == {path.parent.name for path in skill_files()}


@pytest.mark.parametrize("path", skill_files(), ids=lambda path: path.parent.name)
def test_skill_frontmatter_and_body_are_within_the_standard(path):
fields, body = parse_frontmatter(path)

assert fields.get("name") == path.parent.name
assert NAME_PATTERN.match(fields["name"])

description = fields.get("description", "")
assert description
assert len(description) <= MAX_DESCRIPTION_CHARS

assert len(body) <= MAX_SKILL_BODY_LINES