Skip to content

Commit 2b8d1ef

Browse files
docs: adopt the fleet agent-context standard (#13) (#15)
Adds a root AGENTS.md carrying what this repo owns and does not own, the build and test commands transcribed from .github/workflows/ci.yml, the cross-repo parity invariant with saas-sdk-go, and the behavioural rules from obin-ai/handbook#68. Procedural knowledge goes to .claude/skills/ rather than into the root file: regenerating the protobuf bindings, and changing anything covered by a fixture shared byte-for-byte with the Go SDK. CLAUDE.md is the one-line @AGENTS.md pointer the standard asks for, and tests/test_agent_context.py holds the whole arrangement in place — length budget, pointer, and a skill list that cannot drift from the directories. Co-authored-by: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 3cfb4f4 commit 2b8d1ef

5 files changed

Lines changed: 327 additions & 0 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
name: regenerate-bindings
3+
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.
4+
---
5+
6+
# Regenerating the protobuf bindings
7+
8+
`src/saas_sdk/_gen` is generated, never hand-edited. An edit there is silently
9+
reverted by the next regeneration and is not a fix.
10+
11+
## Source of truth
12+
13+
The protos live in `module-saas-starter` at
14+
`module/services/accounts/proto`. Two files are exposed by this SDK:
15+
`saas/accounts/v1/datasource.proto` and `saas/accounts/v1/work_contexts.proto`
16+
(the list is `protos=()` in `scripts/generate.sh`).
17+
18+
The commit those bindings came from is recorded in `SOURCE.txt`. Read it before
19+
regenerating: if the proto change you need is not yet merged in
20+
`module-saas-starter`, that merge is the first pull request, not a local
21+
checkout regenerated against an unmerged branch.
22+
23+
## The walk
24+
25+
From a checkout of `module-saas-starter`:
26+
27+
```bash
28+
scripts/generate.sh <module-saas-starter>/module/services/accounts/proto
29+
```
30+
31+
Requires `buf` and a `python3` with the `protobuf` package importable (the
32+
script calls `scripts/strip_options.py` with plain `python3`, not the project
33+
venv — activate the venv if the system interpreter lacks `protobuf`).
34+
35+
Then, in the same pull request:
36+
37+
1. Update `SOURCE.txt` — the `module-saas-starter` commit SHA, and the module
38+
version if it moved. That version is also this SDK's release version in
39+
`pyproject.toml`; the two are matched on purpose.
40+
2. Run the suite: `python -m pytest -q`.
41+
3. If a new proto file is exposed, add it to `protos=()` in
42+
`scripts/generate.sh`, to the service list in `SOURCE.txt`, and to the
43+
package description in `README.md`.
44+
45+
## Why generation is not plain `buf generate`
46+
47+
The script builds a descriptor image, then `scripts/strip_options.py` removes
48+
the custom options (`buf.validate`, `saas.policy`) and the `google.api` HTTP
49+
annotations together with their shared-proto dependencies, and only then
50+
generates. Without that step the bindings embed shared descriptors and register
51+
them into the **global descriptor pool**, where they collide at import time with
52+
any sibling saas SDK that did the same. The well-known `google.protobuf` types
53+
are deliberately left as runtime types.
54+
55+
Message bindings only: `buf.gen.yaml` generates no client or server stubs,
56+
because the solution runtime owns the Connect transport and this SDK calls
57+
through `Gateway.unary`. Do not add a grpc plugin to get a stub — if a consumer
58+
needs a call this SDK does not expose, the facade
59+
(`src/saas_sdk/datasource.py`) grows a method that names the procedure.
60+
61+
## Version floors
62+
63+
`buf.gen.yaml` pins both plugins to `v29.3`, which is the gencode line matching
64+
the `protobuf>=5.29.3` floor in `pyproject.toml`. Bumping the plugin without the
65+
floor produces bindings that fail to import for consumers on protobuf 5.x;
66+
bumping the floor without the plugin is an unmotivated break of the same
67+
consumers. They move in one change or not at all.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
---
2+
name: wire-format-parity
3+
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.
4+
---
5+
6+
# Changing something the Go SDK also implements
7+
8+
Two fixtures in `tests/fixtures/` are byte-identical to files in
9+
[`saas-sdk-go`](https://github.com/codefly-dev/saas-sdk-go), and CI in both
10+
repos asserts against the same bytes:
11+
12+
| here | there |
13+
| --- | --- |
14+
| `work_context_wire_golden.json` | `TestWorkContextWireGolden` |
15+
| `authorization_revision_projection.json` | `authorizationrevision/testdata/projection.json` |
16+
17+
They exist so the two implementations cannot drift without a test going red.
18+
That makes a red golden test **informative**, not an obstacle.
19+
20+
## If a golden test fails
21+
22+
Do not touch the fixture first. The failure means this implementation no longer
23+
produces (or accepts) the bytes the Go one does, and exactly one of the two is
24+
wrong.
25+
26+
1. Find which side moved. `git log -- tests/fixtures/` and the recent diff in
27+
`src/saas_sdk/work_context.py` usually answer it in one read.
28+
2. If this SDK moved, fix the code here. The encoding is pinned in detail —
29+
field order, `uint64` carried as a decimal *string*, scope canonicalization,
30+
base64url without padding, `key_id` *inside* the payload rather than in a
31+
header. The token is **not** a JWT; do not reach for a JWT library to
32+
"normalise" it.
33+
3. If the Go SDK moved, the fix belongs there, and this is a cross-repo change:
34+
one pull request in each repo, each naming the other, landing together.
35+
36+
Editing the fixture so the local suite goes green, without the matching change
37+
in `saas-sdk-go`, is a hack in the sense the root `AGENTS.md` means: it removes
38+
the only signal that the two implementations disagree, and the disagreement
39+
survives in production, where one side mints what the other rejects.
40+
41+
## Adding a field to the wire format
42+
43+
The payload is closed: `_reject_unknown` in `src/saas_sdk/work_context.py`
44+
rejects any key the verifier does not know, so a field added on the Go side and
45+
not here is not ignored — it is a hard verification failure for every token that
46+
carries it. Sequence it deliberately: verifier support ships and is deployed
47+
before anything mints the new field.
48+
49+
A new field needs, in the same change: parsing in `_unmarshal`, structural
50+
validation in `_validate_work_context` (including the proto's `min_len`
51+
constraints for optional fields), the mint side if a caller sets it, and the
52+
regenerated `work_contexts_pb2` if it is new in the proto (see
53+
`regenerate-bindings`).
54+
55+
## Regenerating a fixture
56+
57+
Only after the Go side is agreed. Copy the file from `saas-sdk-go` verbatim
58+
rather than re-emitting it here — the point of the fixture is that the bytes
59+
came from the other implementation. Then run `python -m pytest -q` and confirm
60+
the golden test verifies the copied token against the copied public key;
61+
`test_wire_golden_payload_matches_fixture_claims_field_by_field` checks the
62+
decoded payload field by field, so a fixture that merely parses still fails if a
63+
claim changed meaning.
64+
65+
Say in the pull request body which Go commit the fixture was taken from, and
66+
whether the paired Go pull request has landed.

‎AGENTS.md‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# saas-sdk-python — agent instructions
2+
3+
This repo is the **Python SDK for the saas accounts API**, the twin of
4+
[`saas-sdk-go`](https://github.com/codefly-dev/saas-sdk-go): a versioned,
5+
published client that solutions depend on instead of regenerating their own
6+
bindings. `README.md` documents the SDK for the people consuming it; this file
7+
is how to work *in* the repo.
8+
9+
## What this repo owns — and does not
10+
11+
- **Owns:** the generated message bindings (`saas_sdk._gen`); the gateway-bound
12+
`datasource` facade; the consumer-side halves of Work Context — the mint
13+
client and the verifier (`saas_sdk.work_context`); the current-revision client
14+
(`saas_sdk.authorization_revision`).
15+
- **Does not own:** the protos, which live in `module-saas-starter`
16+
(`module/services/accounts/proto`); the Connect transport, which the solution
17+
runtime owns behind `Gateway.unary`; the Work Context *signer*, which stays
18+
authority-side in `sdk-go`; the wire format, which is pinned to `sdk-go`.
19+
- **Never hand-edit `src/saas_sdk/_gen/`.** It is regenerated from a proto ref
20+
recorded in `SOURCE.txt` — see the `regenerate-bindings` skill.
21+
- Everything ships under the single top-level package `saas_sdk`, so installing
22+
it never claims a generic name in a consumer's import namespace. A new module
23+
goes inside that package, never beside it.
24+
- The runtime takes **no** dependency on this SDK. If a change here would
25+
require one, the change is in the wrong repo.
26+
27+
## Build and test
28+
29+
Transcribed from `.github/workflows/ci.yml`, which runs the matrix Python
30+
**3.10** and **3.12** on every pull request:
31+
32+
```bash
33+
python -m pip install --upgrade pip
34+
pip install -e ".[test,revision-connect,revision-grpc]"
35+
python -m pytest -q
36+
```
37+
38+
Both networking extras are installed deliberately. `revision-connect` (httpx)
39+
and `revision-grpc` (grpcio) are optional for consumers but not for the suite:
40+
the tests import `grpc` and `httpx` at module scope, so a missing extra fails
41+
collection loudly instead of quietly skipping the transport tests. There are no
42+
`skipif` markers in `tests/` — keep it that way.
43+
44+
Two floors move together and are not independently adjustable:
45+
`requires-python = ">=3.10"` in `pyproject.toml`, and `protobuf>=5.29.3`, which
46+
is the gencode line the bindings are generated with (`buf.gen.yaml` pins the
47+
plugins to `v29.3`). Raising one without the other makes the published SDK
48+
uninstallable for consumers still on protobuf 5.x.
49+
50+
## Parity with saas-sdk-go is a cross-repo invariant
51+
52+
Two fixtures are byte-identical to their Go counterparts, and CI in both repos
53+
verifies the same bytes:
54+
55+
- `tests/fixtures/work_context_wire_golden.json` ↔ sdk-go's
56+
`TestWorkContextWireGolden`. The wire format is **not** a JWT; field order,
57+
`uint64`-as-decimal-string encoding, scope canonicalization, base64url and
58+
Ed25519 signing are pinned byte-for-byte.
59+
- `tests/fixtures/authorization_revision_projection.json` ↔ the Go SDK's
60+
`authorizationrevision/testdata/projection.json`.
61+
62+
Editing a fixture to make a test here pass is not a fix — it is a silent break
63+
of the other implementation, which will keep minting or verifying the old bytes.
64+
A wire change is one coordinated pair of pull requests. See the
65+
`wire-format-parity` skill.
66+
67+
## When something does not work
68+
69+
These are the fleet's rules, not this repo's, and they are stated here because
70+
an agent reads this file and not the tracker:
71+
https://github.com/obin-ai/handbook/issues/68.
72+
73+
- **A gap in the tooling is a bug in the tooling** — never a reason to reach
74+
around it. Not as a "workaround", not "just this once", not "until the verb
75+
lands". A missing capability in `codefly`, `buf` or the generation scripts is
76+
an issue against the thing that owns it.
77+
- **Never hack. Always provide the best fix, even when it spans repos.** The
78+
right fix living in `module-saas-starter`, `sdk-go` or the solution runtime is
79+
not a reason to work around it here — open the pull request there. When it
80+
genuinely cannot be fixed now, the deliverable is a precise issue against the
81+
owner plus a stopgap that says in the code and in the pull request body that
82+
it is one. Never an unlabelled one.
83+
- **Classify every change that makes something work**, in the pull request body:
84+
a *fix* at the place that owns the behaviour, or a *hack*. A hack does not
85+
become a fix by working, by being small, by being local, or by the real fix
86+
belonging elsewhere.
87+
- **Never hardcode what the system resolves.** This SDK exists to be the thing
88+
that resolves. `datasource.new(gateway)` routes through the runtime's
89+
`Gateway.unary` rather than typing an endpoint; `JWKSVerifier` discovers
90+
signing keys through the published JWKS rather than pinning a key; the
91+
revision client loads its credential through a caller-supplied async callback
92+
rather than reading one out of another component's config. A literal endpoint,
93+
token, port or key in this package is a defect, and a test fixture that needs
94+
one builds it locally.
95+
- **Diagnose, do not pattern-match.** "It started working when I set X" is not a
96+
diagnosis — set X back and confirm it breaks. Do not trust an error message
97+
before checking that it says what it appears to say. Nearly everything in
98+
`work_context` raises the one `WorkContextError` type, so the type discriminates
99+
nothing and only the message does; and `JWKSVerifier` refreshes at most once per
100+
cache generation on an unknown `key_id`, so a token that fails and then passes on
101+
a retry has told you about key rotation, not about flakiness.
102+
- **A silent skip is a failed test.** Nothing here may pass by not running.
103+
Never guard a test on an optional extra being importable — install the extra
104+
(CI does) so the test runs or the suite fails.
105+
- **Say what you did not verify.** Unverified is not the same as working. The
106+
matrix is 3.10 and 3.12; if you ran one interpreter, or could not exercise a
107+
transport, the pull request body says so in as many words.
108+
109+
## Procedures
110+
111+
Step-by-step procedures live in `.claude/skills/`, read only when the task needs
112+
them rather than carried here:
113+
114+
<!-- skills -->
115+
- `regenerate-bindings` — regenerating `src/saas_sdk/_gen` from the accounts
116+
proto, including the option-stripping step and `SOURCE.txt`.
117+
- `wire-format-parity` — changing anything covered by a fixture shared
118+
byte-for-byte with `saas-sdk-go`.
119+
<!-- /skills -->
120+
121+
`tests/test_agent_context.py` holds that list to the directories that exist, so
122+
a renamed or deleted skill cannot leave a pointer here aimed at nothing. It also
123+
holds this file to its length budget.
124+
125+
Keep this file under ~200 lines. Context an agent needs only in one subtree
126+
belongs in a nested `AGENTS.md` beside that subtree, and a procedure belongs in
127+
a skill — never appended here.

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

‎tests/test_agent_context.py‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Agent context files hold their shape: the root file stays short, CLAUDE.md
2+
stays a pointer to it, and the skill list cannot drift from the directories."""
3+
4+
import re
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
ROOT = Path(__file__).resolve().parents[1]
10+
AGENTS = ROOT / "AGENTS.md"
11+
SKILLS = ROOT / ".claude" / "skills"
12+
13+
MAX_AGENTS_LINES = 200
14+
MAX_SKILL_BODY_LINES = 500
15+
MAX_DESCRIPTION_CHARS = 1024
16+
NAME_PATTERN = re.compile(r"^[a-z0-9-]{1,64}$")
17+
18+
19+
def skill_files():
20+
return sorted(SKILLS.glob("*/SKILL.md"))
21+
22+
23+
def parse_frontmatter(path):
24+
lines = path.read_text().splitlines()
25+
assert lines and lines[0] == "---", f"{path} does not open with frontmatter"
26+
end = lines.index("---", 1)
27+
fields = {}
28+
for line in lines[1:end]:
29+
key, _, value = line.partition(":")
30+
fields[key.strip()] = value.strip()
31+
return fields, lines[end + 1 :]
32+
33+
34+
def test_agents_file_stays_within_its_length_budget():
35+
lines = AGENTS.read_text().splitlines()
36+
assert len(lines) <= MAX_AGENTS_LINES
37+
38+
39+
def test_claude_md_is_only_a_pointer_to_agents_md():
40+
assert (ROOT / "CLAUDE.md").read_text().strip() == "@AGENTS.md"
41+
42+
43+
def test_listed_skills_are_exactly_the_skills_that_exist():
44+
listed = set(
45+
re.findall(
46+
r"^- `([a-z0-9-]+)`",
47+
AGENTS.read_text().split("<!-- skills -->")[1].split("<!-- /skills -->")[0],
48+
re.MULTILINE,
49+
)
50+
)
51+
assert listed
52+
assert listed == {path.parent.name for path in skill_files()}
53+
54+
55+
@pytest.mark.parametrize("path", skill_files(), ids=lambda path: path.parent.name)
56+
def test_skill_frontmatter_and_body_are_within_the_standard(path):
57+
fields, body = parse_frontmatter(path)
58+
59+
assert fields.get("name") == path.parent.name
60+
assert NAME_PATTERN.match(fields["name"])
61+
62+
description = fields.get("description", "")
63+
assert description
64+
assert len(description) <= MAX_DESCRIPTION_CHARS
65+
66+
assert len(body) <= MAX_SKILL_BODY_LINES

0 commit comments

Comments
 (0)