diff --git a/.claude/skills/regenerate-bindings/SKILL.md b/.claude/skills/regenerate-bindings/SKILL.md new file mode 100644 index 0000000..6e91a21 --- /dev/null +++ b/.claude/skills/regenerate-bindings/SKILL.md @@ -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/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. diff --git a/.claude/skills/wire-format-parity/SKILL.md b/.claude/skills/wire-format-parity/SKILL.md new file mode 100644 index 0000000..7d6602a --- /dev/null +++ b/.claude/skills/wire-format-parity/SKILL.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..24d3dd5 --- /dev/null +++ b/AGENTS.md @@ -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: + + +- `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`. + + +`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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/tests/test_agent_context.py b/tests/test_agent_context.py new file mode 100644 index 0000000..63c091b --- /dev/null +++ b/tests/test_agent_context.py @@ -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("")[1].split("")[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