Plan a Typed Simpro Client Layer
Audit and validate a plan for CLIVE's typed synchronous Simpro client.
Introduction
30 Second Summary
Software changes get risky when a plan ignores what already works. A sensible design can quickly become an expensive rewrite.
In this project, you will produce a repository-specific implementation contract for CLIVE's typed, synchronous Simpro client at commit 5c393e2ab8a5b92f64bdfaf497e13af4902847e9. The contract turns twelve read-only resource requirements into reviewed milestones that preserve the existing project boundaries.
What You'll Build
You hand the finished plan to a senior Python developer who can execute one approved milestone without guessing what to change or preserve.
By the end of this project, you'll have:
- A pinned repository baseline that separates existing Sprint 1 behavior from the gaps Sprint 4 must close.
- A twelve-resource API contract matrix you can compare with the local mock's payloads and routes to prove every field has a source.
- A reviewable implementation sequence that shows how typed endpoints, lazy pagination, shared request policies, and isolated tests fit around the existing raw client.
- Secret Mission: Re-run the audit against a local Git checkout. Prove that the proposed change boundary matches the developer's real working tree.
Are there any prerequisites?
You need public access to the cvc-arus/CLIVE-I repository. The first milestone checks every required local tool before the audit begins.
Before We Start
Before the repository audit begins, this checkpoint fixes Sprint 4 at commit 5c393e2ab8a5b92f64bdfaf497e13af4902847e9 around one goal: plan a typed synchronous read-only Simpro client that preserves Sprint 1 public raw-client behavior plus Sprint 3 mock database boundaries. Real Simpro API calls, write endpoints, a FastAPI client wrapper, Phase 2 PostgreSQL or PGVector changes, plus Sprint 5 hardening stay outside the plan while local Git, uv, Python 3.12.3, plus Docker availability remain prerequisites to check.
Freeze the Repository Baseline
The repository already contains a working Sprint 1 transport. Its Sprint 3 mock also includes tracked generated artifacts plus collection-time side effects that could blur the Sprint 4 change.
A reproducible Git baseline gives the plan a verifiable commit plus a clean-tree checkpoint. You will branch from that evidence before recording the dependency state and current test behavior.
In this step, get ready to:
- Confirm Git plus uv 0.12.9 plus Python 3.12.3 are available.
- Establish a clean feature branch from the reviewed commit.
- Record the dependency state plus collection behavior plus approved file boundary.
Check the local toolchain
The baseline commands depend on Git plus uv plus the required Python environment. Checking each tool first keeps a missing installation from looking like a repository defect.
Use PowerShell for these checks so every command runs in the same shell.
- Press the Windows key to open the search bar.
- Type PowerShell in the search bar.
- Press Enter to open PowerShell.
- Check whether Git is available by running this command:
git --version
What does this check prove?
This command asks the installed Git executable to report its version. A version line confirms that PowerShell can access Git.
- Select the tab that matches the Git output.
✔️ I see a Git version
Git is available for the repository checks. Keep this PowerShell window open for the remaining commands.
ⓧ Command not found
Git must be available before the repository can produce trustworthy branch or status evidence.
- Download Git from the official Git installation page.
- Complete the installation for Windows.
- Return to PowerShell after the installer finishes.
- Verify the installation by running this command:
git --version
What confirms the installation?
A Git version line confirms that the new installation is available in PowerShell.
- Check the installed uv version by running this command:
uv self version
Why check the uv version?
The reviewed toolchain uses uv 0.12.9. Matching that version keeps dependency synchronization reproducible.
- Select the tab that matches the uv output.
✔️ I see the pinned uv version
The output shows uv 0.12.9. Your dependency manager matches the reviewed toolchain.
ⓧ I see a different version
A different uv version can resolve or install dependencies differently. Install the reviewed version before capturing the baseline.
- Install uv 0.12.9 by running these commands:
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/0.12.9/install.ps1 | iex"
uv self version
What do these commands do?
The first command runs the version-specific uv installer for 0.12.9. The second command confirms the installed version.
ⓧ Command not found
PowerShell cannot access uv yet. Install the pinned release before continuing.
- Install uv 0.12.9 by running these commands:
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/0.12.9/install.ps1 | iex"
uv self version
What do these commands do?
The installer adds uv 0.12.9 to your machine. The version check proves that PowerShell can access it.
- List the Python installations visible to uv by running this command:
uv python list
What does the Python list show?
The list distinguishes Python versions already installed from versions that uv can install. The reviewed baseline requires Python 3.12.3.
- Select the tab that matches the Python list.
✔️ I see the required Python version
Python 3.12.3 is available to uv. The required interpreter can support the synchronized project environment.
ⓧ I see another 3.12 version
The audit records Python 3.12.3 as the reviewed environment. Install that exact patch release before syncing.
- Install Python 3.12.3 and refresh the version list by running these commands:
uv python install 3.12.3
uv python list
What do these commands do?
The first command installs the exact Python patch release. The second command confirms that uv can find it.
ⓧ Python 3.12 is not listed
The required Python line is missing from the available local installations. uv can install the exact reviewed release.
- Install Python 3.12.3 and refresh the version list by running these commands:
uv python install 3.12.3
uv python list
What do these commands do?
The installation gives uv access to Python 3.12.3. The refreshed list provides visible proof that the interpreter is present.
Your toolchain evidence is ready. Git plus uv plus Python can now support a reproducible repository audit.
Capture the repository baseline
A baseline has two independent facts. The commit SHA identifies the reviewed code while a blank short status proves that local changes are not mixed into it.
Before you check, do you expect the checkout to match the reviewed commit with no working-tree entries?
- Switch to the PowerShell window for the local CLIVE-I checkout.
- Inspect the working tree plus current commit by running these commands:
git status --short
git rev-parse --verify HEAD
What do these commands establish?
The short status shows every tracked or untracked working-tree difference. No output means the checkout is clean.
The revision command prints the exact commit behind HEAD. The reviewed value is 5c393e2ab8a5b92f64bdfaf497e13af4902847e9.
Does the baseline differ?
Keep every status entry if the first command produces output. Record each path before asking the file owner how to preserve the work.
Pause the Sprint 4 branch if the SHA differs from the reviewed value. Help me compare my CLIVE-I checkout with the reviewed Sprint 4 baseline.
- Create the isolated Sprint 4 branch by running this command:
git switch -c sprint4-typed-simpro-client
What does this branch protect?
The command creates sprint4-typed-simpro-client from the verified commit. Future approved work stays separate from the original branch.
Does the branch already exist?
Do not delete or overwrite an existing branch with this name. Confirm who created it before deciding whether it already represents the approved Sprint 4 work.
Ask for help before changing branch history. Help me inspect an existing Sprint 4 branch safely.
- Synchronize the locked project environment by running this command:
uv sync
What does the synchronization include?
uv uses uv.lock to synchronize the project environment. It installs the project in editable form plus the default dev dependency group.
Did dependency synchronization fail?
Confirm that PowerShell still reports uv 0.12.9 plus Python 3.12.3. Keep the complete resolver output because it identifies the package that blocked synchronization.
Avoid changing dependency pins during this audit. Help me diagnose my uv sync failure without changing CLIVE-I dependency versions.
- Capture the resolved package tree by running this command:
uv tree
Why record the package tree?
The package tree shows which locked packages the environment actually resolved. This evidence makes later dependency changes visible during review.
Which pins belong in the baseline?
- Record HTTPX 0.28.1.
- Record Pydantic 2.13.4.
- Record pydantic-settings 2.14.2.
- Record python-dotenv 1.1.0.
- Record pytest 9.1.1.
- Record RESPX 0.23.1.
- Record Ruff 0.16.1.
- Record pytest-cov 7.0.0.
Pytest collection should discover test modules without executing test traffic. This repository currently contains script-style files that make the collection result part of the baseline evidence.
Before you collect the tests, do you think discovery stays local or reaches the unavailable mock service?
- Capture the current test collection behavior by running this command:
pytest --collect-only -q
Collection reaches the mock
With the mock unavailable, collection fails while importing tests/test_simpro_mock.py because that module performs HTTP requests at import time. This is the intended baseline finding.
Record the exact failure without changing the test yet. If collection succeeds, record that result plus whether the mock was reachable during the command.
Lock the approved change boundary
The baseline shows which capabilities Sprint 4 can extend. It also separates planned cleanup from functional typed-client work.
What already exists in Sprint 1?
- Authentication already obtains plus caches access tokens.
- A one-time 401 retry already invalidates the cached token before trying again.
- The public get, post, patch, plus delete methods already return raw JSON for successful responses.
- Typed exception foundations already distinguish authentication plus API plus rate-limit plus not-found failures.
- Structured logging already uses correlation context.
- Every outbound request already sends X-Correlation-ID.
What is the Sprint 4 file boundary?
- Create later: src/simpro_client/models/, src/simpro_client/endpoints/, src/simpro_client/pagination.py, src/simpro_client/rate_limiter.py, focused unit-test modules, plus docs/phase3-sprint4.md.
- Modify later: src/simpro_client/client.py, src/simpro_client/config.py, src/simpro_client/exceptions.py, src/simpro_client/__init__.py, tests/conftest.py, tests/test_client.py, tests/test_simpro_mock_v2.py, scripts/verify-simpro-mock.py, pyproject.toml, .env.example, plus docs/phase3.md.
- Remove or relocate later: tests/test_simpro_mock.py, tests/test_manual_logging.py, tracked __pycache__ trees, plus tracked src/simpro_client.egg-info/ metadata.
- Leave untouched: Phase 2 PostgreSQL plus PGVector resources, backups, Phase 1 plus Phase 2 documentation, services/simpro_mock/alembic/, services/simpro_mock/simpro_mock/models.py, services/simpro_mock/simpro_mock/database.py, plus every write path.
- Permit one mock exception: services/simpro_mock/simpro_mock/middleware.py may receive a minimal correlation-header echo or capture change only when live integration proof requires it.
- Copy the approved boundary into the Sprint 4 planning record.
- Record every local discrepancy as a plan amendment.
- Keep all functional files unchanged during this milestone.
The approved planning record contains the repository baseline plus file boundary plus unchanged Sprint 4 scope.
✔️ I'll try it first
Your record is complete when it captures the actual checkout evidence plus every approved boundary category. No functional source file has changed.
ⓧ Let me check my work
Repository baseline
Documented baseline from git status --short, git rev-parse --verify HEAD, git switch -c sprint4-typed-simpro-client, uv sync, and pytest --collect-only -q. Records the actual SHA, clean or non-clean working-tree status, dependency state, package tree, collection result, and any collection-time network side effect. No functional files changed.
Sprint 4 file boundary
Create later: src/simpro_client/models/, src/simpro_client/endpoints/, src/simpro_client/pagination.py, src/simpro_client/rate_limiter.py, focused tests, and docs/phase3-sprint4.md. Modify later: client.py, config.py, exceptions.py, __init__.py, selected tests, scripts/verify-simpro-mock.py, pyproject.toml, .env.example, and docs/phase3.md. Remove or relocate later: obsolete mock and manual logging tests, tracked __pycache__ trees, and src/simpro_client.egg-info metadata. Leave Phase 2 resources, Phase 1/2 docs, mock alembic, mock models.py, mock database.py, and all write-path behavior untouched. middleware.py may receive only a minimal correlation-observability change if required.
Sprint 4 scope
Planning scope fixed at commit 5c393e2ab8a5b92f64bdfaf497e13af4902847e9: typed synchronous read-only Simpro client work only. Preserve Sprint 1 public behavior and Sprint 3 database/mock boundaries. Exclude real API access, writes, FastAPI wrapper work, Phase 2 storage changes, PGVector merge work, and Sprint 5 hardening.
Before the final check, do you expect HEAD to remain on the reviewed commit with a blank short status?
- Verify the pinned commit plus unchanged working tree by running these commands:
git rev-parse --verify HEAD
git status --short
What confirms the frozen baseline?
The first command reports 5c393e2ab8a5b92f64bdfaf497e13af4902847e9. The second command prints nothing.
Together they prove that the new branch still points at the reviewed baseline plus no functional file has changed.
Does the final status show changes?
Review every listed path before continuing. Preserve unexpected files plus record why they differ from the approved baseline.
Do not use a destructive cleanup command. Help me review unexpected Git status entries without losing local work.
That is the baseline frozen. Your Sprint 4 branch now has a documented starting point plus a reviewable boundary.
The repository evidence is recorded without a functional edit. Next, you will map the twelve typed resource contracts against the committed mock.
Map the Typed Resource Contracts
Your repository baseline now separates existing Sprint 1 behavior from the changes proposed for Sprint 4. The next milestone defines the data boundary for every typed read result.
The mock emits PascalCase JSON fields plus ISO date strings. A sourced API contract gives application code stable attributes while preserving the committed mock payloads.
In this step, get ready to:
- Map the payload fields for all 12 read-only resources.
- Trace each resource to its committed list route.
- Define the planned model package plus its isolated test gate.
Map the payload fields
Each model exposes snake_case attributes while accepting the exact field names returned by the mock. A shared Pydantic configuration keeps that behavior consistent across the package.
- Return to the local checkout from your baseline audit.
- Inspect services/simpro_mock/simpro_mock/schemas.py to identify the committed response fields.
You should find one payload contract for each of the 12 resources in scope. These declarations are the authority for aliases, scalar types, and optionality.
- Create a 12-row payload matrix in the Sprint 4 implementation contract by copying each field declaration from services/simpro_mock/simpro_mock/schemas.py.
Payload Contract Reference
Use the committed JSON field as the explicit alias. Use the matching snake_case name as the Python attribute.
- Company: ID maps to id. Name maps to name. Both fields are required.
- Customer: ID maps to id. CompanyID maps to company_id. GivenName maps to given_name. FamilyName maps to family_name. Email maps to email. Phone maps to phone. Email and phone are optional.
- Job: ID maps to id. CompanyID maps to company_id. Name maps to name. Status maps to status. DateIssued maps to date_issued. Total maps to total. Date issued is an optional date.
- Quote: ID maps to id. CompanyID maps to company_id. CustomerID maps to customer_id. Name maps to name. Status maps to status. Total maps to total. Customer ID is optional.
- Contact: ID maps to id. CompanyID maps to company_id. CustomerID maps to customer_id. GivenName maps to given_name. FamilyName maps to family_name. Position maps to position. Email maps to email. Phone maps to phone. Position, email, and phone are optional.
- Site: ID maps to id. CompanyID maps to company_id. CustomerID maps to customer_id. Name maps to name. Address maps to address. City maps to city. Postcode maps to postcode. State maps to state. Country maps to country. The address fields are optional.
- Asset: ID maps to id. CompanyID maps to company_id. SiteID maps to site_id. AssetNo maps to asset_no. Name maps to name. SerialNo maps to serial_no. Model maps to model. Manufacturer maps to manufacturer. InstalledDate maps to installed_date. Serial number, model, manufacturer, and installed date are optional. Installed date uses date.
- Employee: ID maps to id. CompanyID maps to company_id. GivenName maps to given_name. FamilyName maps to family_name. Position maps to position. Email maps to email. Phone maps to phone. Position, email, and phone are optional.
- Project: ID maps to id. CompanyID maps to company_id. CustomerID maps to customer_id. SiteID maps to site_id. Name maps to name. Status maps to status. Total maps to total. Site ID is optional.
- Job note: ID maps to id. JobID maps to job_id. Subject maps to subject. Note maps to note. CreatedBy maps to created_by. CreatedAt maps to created_at. Subject, note, creator, and creation time are optional. Creation time uses datetime.
- Attachment: ID maps to id. JobID maps to job_id. Filename maps to filename. MimeType maps to mime_type. FileSize maps to file_size. UploadedAt maps to uploaded_at. MIME type, file size, and upload time are optional. Upload time uses datetime.
- Status: ID maps to id. CompanyID maps to company_id. Name maps to name. Category maps to category. IsDefault maps to is_default. Category is optional. Is default is a required boolean.
Your matrix now identifies the committed alias for every field. Copy each remaining scalar type directly from the corresponding schema declaration.
- Record src/simpro_client/models/base.py as the planned home of the shared model configuration.
- Record src/simpro_client/models/__init__.py as the planned model export surface.
The package plan now has a shared base plus a stable export surface. Each resource still needs its own thin model module.
- Record company.py, customer.py, job.py, quote.py, contact.py, site.py, asset.py, employee.py, project.py, job_note.py, attachment.py, and status.py under src/simpro_client/models/.
- Record extra="ignore", validate_by_alias=True, and validate_by_name=True in the shared model configuration.
Why Use Explicit Aliases?
Fields such as ID, CompanyID, JobID, MimeType, and IsDefault use acronym-heavy casing. Explicit aliases preserve those committed names while exposing predictable snake_case attributes.
The unknown-field policy accepts additive payload fields. Missing required fields still produce validation failures.
Trace the list routes
A typed model is useful only when its endpoint uses the correct route scope. The committed router places several resources below customers, sites, or jobs.
- Inspect services/simpro_mock/simpro_mock/routers.py to identify every list route.
- Complete the list-route column in your contract matrix by copying each route from services/simpro_mock/simpro_mock/routers.py.
You should now have one sourced list route beside each payload row. The route scopes should match the following structure.
List Route Scope Reference
- Company uses /api/v1.0/companies/.
- Customer uses a company-scoped list route.
- Job uses a company-scoped list route.
- Quote uses a company-scoped list route.
- Contact uses a customer-scoped list route.
- Site uses a company-scoped list route.
- Asset uses a site-scoped list route.
- Employee uses a company-scoped list route.
- Project uses a company-scoped list route.
- Job note uses a job-scoped list route.
- Attachment uses a job-scoped list route.
- Status uses a company-scoped list route.
- Inspect src/simpro_client/client.py from the documented Sprint 1 baseline.
- Record the public get method's header loss as a pagination constraint.
What Blocks Pagination Today?
The mock reports pagination through Result-Total, Result-Count, and Result-Pages response headers. The current public get method returns parsed response data without those headers.
The Sprint 4 design must preserve pagination metadata inside the typed read path. Existing Sprint 1 callers keep their raw return values.
Define the model verification gate
The model contract needs isolated tests that prove validation without HTTP requests or Docker. One parameterized module keeps the 12 resource cases consistent.
- Record tests/test_models.py as the planned model-only test module.
- Add the complete model-test matrix to the Sprint 4 implementation contract.
Model Test Matrix
- Test explicit alias input for all 12 models.
- Test that unknown fields are ignored.
- Test that missing required fields fail validation.
- Test that optional fields accept their documented empty state.
- Test temporal parsing for DateIssued, InstalledDate, CreatedAt, and UploadedAt.
- Test boolean validation for IsDefault.
- Test round-trip serialization using committed aliases.
- Add the future model verification gate to the implementation contract by copying these commands:
pytest tests/test_models.py -q
ruff check src/simpro_client/models tests/test_models.py
ruff format --check src/simpro_client/models tests/test_models.py
What Do These Gates Prove?
- The first command verifies model behavior without contacting the mock.
- The second command checks the planned model package plus its tests for lint problems.
- The third command checks formatting without changing files.
Before you compare the finished contract, do you expect every field and route to trace back to one committed mock declaration?
- Compare all 12 payload rows with services/simpro_mock/simpro_mock/schemas.py.
- Compare all 12 list routes with services/simpro_mock/simpro_mock/routers.py.
- Confirm that each committed JSON field has one explicit snake_case attribute.
- Confirm that DateIssued plus InstalledDate use date.
- Confirm that CreatedAt plus UploadedAt use datetime.
- Confirm that every other scalar type matches the committed schema.
- Confirm that tests/test_models.py covers every agreed validation behavior.
- Confirm that the public get method's header loss is recorded as the pagination constraint.
You should now have 12 sourced payload rows plus 12 sourced list routes. That is a strong planning result. The typed boundary is complete without changing Sprint 1 behavior or the Sprint 3 mock.
The typed resource contract is now tied to committed payloads and routes. Next up, you will design the generic endpoint layer plus its lazy pagination behavior.
Design Endpoints and Pagination
The typed contracts from the previous step now define every resource payload. They also establish the Python types each endpoint must return.
The existing public get method returns decoded JSON, so it drops the HTTP headers that report total pages. This step defines a response-preserving internal path with lazy pagination while Sprint 1's public methods stay unchanged.
In this step, get ready to:
- Preserve the raw public client behavior through an additive internal response path.
- Define the generic endpoint foundation with explicit route scopes.
- Specify a lazy pagination contract with measurable request-count checks.
Preserve the raw client contract
ResourceEndpoint is the shared boundary between transport responses and typed resource objects. It centralizes the behavior every resource needs.
What belongs in ResourceEndpoint?
- Request dispatch uses the client-owned internal response primitive.
- Query merging combines pagination values with caller-supplied filters.
- Model validation converts each payload into the matching Pydantic resource model.
- Lazy listing creates an iterator without fetching every page.
- Return to the Sprint 4 implementation contract from earlier.
- Add the endpoint boundary contract above to the implementation plan.
The plan now assigns shared endpoint behavior to src/simpro_client/endpoints/base.py. Thin resource modules only need to supply their model details and route scope.
The client needs a private path that keeps the complete response available for endpoint code. Its public methods continue to expose the decoded JSON behavior that Sprint 1 callers already use.
Client Compatibility Contract
- The internal request primitive returns the complete response for endpoint use.
- The public get method keeps returning decoded JSON.
- The public post method keeps returning decoded JSON.
- The public patch method keeps returning decoded JSON.
- The public delete method keeps its existing response behavior.
- Endpoint objects become additive attributes on SimproClient.
- Changes to src/simpro_client/__init__.py remain limited to stable public exports.
- Add the client compatibility contract above to the implementation plan.
- Compare the contract with the Sprint 1 preservation rule from the documented scope.
You should now be able to trace two stable paths. Endpoint code receives response metadata while existing callers receive the same decoded JSON structure.
Map the routes and lazy iterator
Route scope belongs in thin resource modules because the mock exposes several nesting patterns. Explicit scopes prevent nested resources from inheriting an incorrect company route.
Route Scope Matrix
- Companies use the top-level /api/v1.0/companies/ collection.
- Customers use company scope.
- Jobs use company scope.
- Quotes use company scope.
- Contacts use customer scope.
- Sites use company scope.
- Assets use site scope.
- Employees use company scope.
- Projects use company scope.
- Job notes use job scope.
- Attachments use job scope.
- Statuses use company scope.
- Copy the route-scope matrix into the endpoint section of the implementation contract.
- Compare all twelve entries with the typed resource contract from earlier.
Your comparison should account for every resource exactly once. Contacts, assets, job notes, and attachments should retain their explicit nested scopes.
The iterator controls when requests happen and how much data stays in memory. Its contract must make each page boundary observable in a focused test.
How Lazy Iteration Behaves
- A list operation returns a lazy iterator.
- Creating the iterator sends no request.
- Consuming the first item fetches page one.
- The iterator retains only the current page.
- Crossing a page boundary fetches exactly one subsequent page.
- The Result-Pages response header provides the total-page count.
- An empty result remains a valid one-page response with no items.
- A missing total-page header raises a typed protocol error.
- A malformed total-page header raises a typed protocol error.
- Add the iterator contract above under src/simpro_client/pagination.py in the implementation plan.
- Add unchanged filter keys and operator expressions to the endpoint query contract.
The design now covers request timing, memory use, pagination metadata, and filter compatibility.
- Trace the first item and first page boundary through the completed iterator rules.
Your trace should show one request at first consumption. It should show exactly one additional request when iteration crosses the page boundary.
Define the focused verification gates
A useful implementation contract states how each design promise becomes observable. The endpoint tests cover route behavior while pagination tests count requests at consumption boundaries.
Planned Test Matrix
- tests/test_endpoints.py covers all route templates.
- tests/test_endpoints.py covers company selection.
- tests/test_endpoints.py confirms filter values pass through unchanged.
- tests/test_endpoints.py covers empty results.
- tests/test_endpoints.py covers model validation.
- tests/test_pagination.py covers one-page iteration.
- tests/test_pagination.py proves iterator creation sends no request.
- tests/test_pagination.py proves first consumption sends one request.
- tests/test_pagination.py proves crossing the boundary sends one additional request.
- tests/test_client.py confirms the legacy raw GET path returns its existing JSON structure.
- Add the planned test modules to the verification section.
- Record the focused command sequence by adding:
pytest tests/test_endpoints.py tests/test_pagination.py tests/test_client.py -q
ruff check src/simpro_client/endpoints src/simpro_client/pagination.py src/simpro_client/client.py
ruff format --check src/simpro_client/endpoints src/simpro_client/pagination.py src/simpro_client/client.py
What Do These Gates Prove?
- The focused pytest command checks endpoint behavior, pagination behavior, and legacy client compatibility together.
- The Ruff lint command checks the planned endpoint paths plus the additive client changes.
- The Ruff formatting command confirms those same paths match the repository format.
- These commands become implementation gates when the planned modules are created.
Before you compare the finished contract, do you expect the legacy raw get result to change?
- Compare your completed endpoint and pagination design with the checkpoint below.
✔️ My design matches the contract
Your contract preserves the raw client methods. It also defines typed endpoints, explicit route scopes, lazy page boundaries, and focused verification gates.
ⓧ I'd like to double check the design
Your completed checkpoint should include:
- src/simpro_client/endpoints/base.py defines ResourceEndpoint for shared request handling, query merging, Pydantic validation, and lazy listing.
- Thin endpoint modules define explicit company, customer, site, and job route scopes across all twelve resources.
- src/simpro_client/pagination.py fetches page one on first consumption.
- The iterator retains only one page.
- The iterator reads Result-Pages for the total-page count.
- The iterator fetches later pages only at page boundaries.
- Missing pagination headers raise a typed protocol error.
- Malformed pagination headers raise a typed protocol error.
- src/simpro_client/client.py gains endpoint objects plus an internal response-returning primitive.
- The public get, post, patch, and delete behavior remains compatible with Sprint 1.
- Filter keys and operator expressions pass through unchanged.
- tests/test_endpoints.py covers templates, selection, filters, empty results, and model validation.
- tests/test_pagination.py covers one-page and multi-page request-count laziness.
- tests/test_client.py protects the existing raw GET output.
That locks in the endpoint boundary. You should see a complete design with no functional code edits and no change to the legacy raw GET result.
The typed read path now has an implementation-ready contract. Next, you'll design the shared request policies that govern every outbound attempt.
Design Resilience and Error Handling
Your endpoint design now knows how to fetch typed pages. Every call still relies on the Sprint 1 Simpro request policy that raises immediately on 429.
This step specifies ADR-005 at the shared client boundary. A client-owned token bucket will govern every outbound attempt.
The public JSON methods keep their Sprint 1 behavior. The typed endpoints gain one predictable synchronous policy for retries, failures, and each correlation ID.
In this step, get ready to:
- Place one shared rate limiter at the existing client request boundary.
- Define separate authentication and rate-limit retry rules.
- Specify typed failures and deterministic acceptance tests.
Define the shared request boundary
Every endpoint object uses the same client. Placing the limiter there gives all initial requests and retries one shared allowance.
- Specify src/simpro_client/rate_limiter.py as the home of one lock-protected token bucket with a default of eight requests per second.
- Assign that token bucket to each SimproClient instance.
- Review the planned request flow to confirm every outbound attempt reaches the client-owned limiter.
Your flow should show one limiter serving every endpoint. Retries pass through the same limiter as initial attempts.
- Add validated capacity and refill-rate settings to the planned config.py changes.
- Retain the existing retry setting in the config.py contract.
- Confirm the contract keeps the limiter lock released during any sleep.
The completed limiter design protects its token calculations without blocking unrelated callers during a wait.
- Record the non-secret optional settings in the planned .env.example changes.
The configuration boundary now exposes tuning without adding a dependency or changing the synchronous client.
Why place the limiter on the client?
A limiter attached to each endpoint would create separate request allowances. A client-owned limiter gives all 12 resources one coordinated policy.
The lock protects token accounting only. Releasing it before sleep keeps other callers from waiting on the lock itself.
Good. Your endpoint layer now has one request allowance instead of 12 independent limits.
Specify retry, error, and correlation semantics
Authentication recovery and rate-limit recovery solve different failures. The contract keeps their retry budgets independent so existing behavior stays stable.
- Record the existing one-time 401 token invalidation and refresh rule.
- Define bounded 429 retries that honor valid Retry-After values before fallback backoff.
- Trace a logical request through one 401 response followed by one 429 response.
Your trace should show one token refresh. The later rate-limit response should still have its complete retry budget.
How Does Retry-After Fit?
A valid Retry-After value can contain an HTTP-date or a non-negative decimal number of delay seconds. That value takes precedence for a 429 response.
A missing or invalid value activates bounded exponential backoff with jitter. The existing maximum retry count stops the loop.
- Extend the exceptions.py plan with backward-compatible client-error, server-error, rate-limit, and protocol-error categories.
- Preserve the method, URL, status, response body, retry metadata, and correlation ID on each raised failure.
- Compare the planned categories against 4xx and 5xx outcomes.
The exception plan should retain the existing hierarchy while giving callers enough context to diagnose one failed request.
- Keep one correlation ID from the existing ContextVar for every attempt in one logical request.
- Reuse the existing X-Correlation-ID outbound header across those attempts.
- Inspect the retry flow to confirm the correlation value remains unchanged.
The first attempt and every retry should carry the same value. Logs and raised exceptions should preserve that value too.
- Restrict any planned services/simpro_mock/simpro_mock/middleware.py change to exposing the existing correlation header for live verification.
The mock database boundary remains untouched. Middleware work stays optional until the integration test needs observable proof.
Lock down deterministic acceptance tests
Retry tests become reliable when time is under test control. RESPX sequential responses can model authentication failures, rate limits, and recovery without contacting a live service.
- Add tests/test_rate_limiter.py and tests/test_retries.py to the planned test map.
- Add focused request-context assertions to the planned tests/test_client.py changes.
- Review the test matrix for one shared bucket, one-time authentication recovery, bounded rate-limit retries, typed failures, and stable correlation.
The matrix should cover each policy branch at the shared client boundary. Endpoint modules should need no duplicate resilience tests.
- Inject clock, sleeper, and random sources into the planned tests.
- Use RESPX sequential responses for retry scenarios.
- Check the test design for real sleep or live network access.
You should find neither. Every delay and response remains deterministic.
- Record the focused implementation gates in the contract by adding these commands:
pytest tests/test_rate_limiter.py tests/test_retries.py tests/test_client.py -q
ruff check src/simpro_client/rate_limiter.py src/simpro_client/client.py src/simpro_client/config.py src/simpro_client/exceptions.py
ruff format --check src/simpro_client/rate_limiter.py src/simpro_client/client.py src/simpro_client/config.py src/simpro_client/exceptions.py
What Do These Gates Prove?
- The first command exercises the limiter, retry policy, and legacy client behavior together.
- The second command checks the affected Python modules for Ruff findings.
- The third command confirms those modules match the repository formatting rules.
- Confirm the acceptance contract places all three commands after the deterministic test criteria.
Before you compare the final contract, do you expect any retry branch to bypass the limiter or replace the correlation ID?
✔️ I'll try it first
- Confirm every outbound attempt reaches the same client-owned limiter.
- Confirm the 401 refresh rule remains outside the 429 retry budget.
- Confirm each logical request keeps one correlation ID across all attempts.
You should find all three guarantees in the contract. Each failure path should also preserve complete request context.
ⓧ Let me check my work
Planned ADR-005 implementation: src/simpro_client/rate_limiter.py provides one lock-protected client-owned token bucket with default eight requests per second, configurable capacity and refill rate, and no sleep while locked.
Every request attempt is limited. Existing one-time 401 invalidation and refresh remains separate from the 429 retry budget.
Valid Retry-After takes precedence over bounded exponential backoff with jitter for 429 responses. Existing correlation ContextVar and outbound header keep one correlation ID across retries.
exceptions.py gains backward-compatible request context and typed client, server, rate-limit, and protocol failures with method, URL, status, body, retry metadata, and correlation ID.
Planned tests/test_rate_limiter.py, tests/test_retries.py, and additive client tests inject time, sleep, and randomness and make no live network calls.
That is a major design risk contained. Next, you will connect this policy to the integration, documentation, quality, and commit gates.
Define Quality and Commit Gates
Your typed client plan now covers the repository baseline through shared request resilience. The remaining risk is a false green build that hides collection-time network access or unreviewed repository changes.
This step closes that gap with executable test gates. It also defines the documentation, cleanup, review, and commit evidence required to complete Sprint 4.
In this step, get ready to:
- Define an offline unit-test gate.
- Define runtime-gated integration evidence.
- Define repository quality and commit gates.
Separate unit and integration evidence
Safe pytest collection imports test modules without contacting a service. This keeps the unit suite independent of Docker and the local mock.
- Require the non-integration suite to pass while Docker is stopped.
- Require test collection to perform no network request.
These requirements expose collection side effects even when every isolated unit assertion passes.
- Gate service availability inside an integration fixture at runtime.
- Mark the live integration suite as read-only.
An unavailable simpro-mock causes a safe runtime skip. Collection remains available for every developer environment.
- Cover 401 retries with RESPX sequential responses.
- Cover bounded 429 retries with RESPX sequential responses.
- Cover lazy multi-page requests with RESPX sequential responses.
Deterministic response sequences prove retry counts and page boundaries without real sleep or live network access.
- Assert outgoing query parameters in endpoint tests.
- Assert X-Correlation-ID propagation across retries.
These assertions connect the endpoint contract to the shared request policy.
- Cover authentication plus both configured company IDs in the live suite.
- Cover filtering plus empty results in the live suite.
The live checks prove the typed client works against both seeded company scopes without contacting the real API.
- Cover multi-page model return types in the live suite.
- Cover correlation propagation through the mock in the live suite.
- Record the unit and integration gate sequence exactly as follows:
pytest -q -m 'not integration'
docker compose up -d --build simpro-mock
pytest -q -m integration
What do these gates prove?
- The first command proves unit tests pass with Docker stopped.
- The second command rebuilds the mock service and starts it in the background.
- The third command proves the read-only client contract against the running mock.
Remove collection side effects and document the boundary
Test layout is part of the quality contract because module-level requests can fail before pytest reaches a test. The cleanup plan must remove those side effects without weakening live coverage.
- Mark tests/test_simpro_mock.py for deletion.
- Mark tests/test_simpro_mock_v2.py for refactoring into a fixture-gated integration module.
The replacement integration module keeps service checks inside fixture setup. An unavailable mock therefore affects test execution instead of collection.
- Mark tests/test_manual_logging.py for relocation or conversion.
- Mark the test-prefixed diagnostic helpers in scripts/verify-simpro-mock.py for renaming.
The diagnostic script remains useful while pytest stops treating script-style helpers as test candidates.
- Correct the filename shown in the usage text for scripts/verify-simpro-mock.py.
- Register the integration marker in pyproject.toml without changing dependency pins.
The marker gives the suite an explicit offline path and an explicit live path.
- Remove tracked __pycache__ trees from the planned Sprint 4 diff.
- Remove tracked src/simpro_client.egg-info/ metadata from the planned Sprint 4 diff.
Both artifact types are generated locally. Their removal leaves the commit focused on source, tests, and documentation.
- Add docs/phase3-sprint4.md to the documentation plan.
- Add the Sprint 4 architecture summary to docs/phase3.md.
The documentation must capture the file boundary, environment settings, test commands, cleanup behavior, and rollback path.
- Record the python-dotenv security advisory as Sprint 5 hardening work.
Where does Sprint 5 begin?
Sprint 4 keeps the existing dependency pins so its typed read path stays reviewable. The security advisory remains visible as a separate hardening item instead of expanding this implementation boundary.
Define the quality, commit, and cleanup sequence
A release gate converts the implementation contract into evidence. Ruff checks code quality before Git records the approved change.
- Record the static quality sequence exactly as follows:
ruff check .
ruff format --check .
git diff --check
git status --short
What does the quality sequence check?
- The Ruff commands check lint rules and formatting without rewriting files.
- The diff check rejects whitespace errors and conflict markers.
- The short status exposes every staged, modified, deleted, or untracked path before the commit.
- Require the implementation owner to review every changed path in the Git diff.
- Require the reviewed paths to match the approved Sprint 4 file boundary.
What must stay outside the diff?
- The diff contains no Phase 2 database changes.
- The diff contains no real Simpro API URL or secret.
- The diff contains no async database code or write endpoint.
- The diff contains no client wrapper or Sprint 5 hardening change.
Staging the whole tree is the risky point because one unrelated local file can enter the commit. The reviewed diff and short status make that risk visible first.
- Record the staging and commit sequence exactly as follows:
git add -A
git commit -m "feat: add typed Simpro client layer"
git status --short
What proves the commit is complete?
- The staging command records every reviewed addition, modification, and deletion.
- The commit command creates one intentional Sprint 4 change with the approved message.
- The final status command produces no output when the working tree is clean.
The mock database uses a named volume that may contain useful local state. Normal cleanup stops the local stack while preserving that volume.
- Record the normal mock cleanup command exactly as follows:
docker compose down
Why use this cleanup command?
This command removes the running containers and their networks. It preserves named volumes because the volume-removal option is absent.
The normal Sprint 4 cleanup path must keep the mock database volume intact.
Before you check the finished contract, which gate do you think is most likely to catch an accidental Phase 2 change?
- Compare the finished contract with the approved Sprint 4 file boundary.
- Confirm every required command appears in the order defined above.
- Confirm the contract ends with a clean post-commit status requirement.
You should find offline unit evidence plus runtime-gated integration evidence. The live suite should remain read-only and safe to skip when the mock is unavailable.
The release sequence should require passing Ruff checks before a reviewed commit. It should end with an empty short status.
The cleanup rule should preserve the mock database volume. The dependency advisory should remain inside the Sprint 5 boundary.
That locks the release boundary. Sprint 4 now has an implementation contract that a senior Python developer can execute one reviewed milestone at a time.
Secret mission
Validate the Plan Against a Local Checkout
The reviewed repository proves what was committed, but your local checkout may contain newer commits or unfinished work. Run a non-destructive preflight that exposes every difference before Sprint 4 implementation begins.
Clean Up Your Resources
Clean Up Your Resources
This planning project creates no paid cloud resources. Choose whether to keep the Sprint 4 branch ready, pause work on it, or remove it through your team's reviewed Git process.
The existing CLIVE-I checkout and all pre-existing local changes remain outside this cleanup. The local mock is already stopped.
Resources you used:
- Local branch sprint4-typed-simpro-client, created for the reviewed Sprint 4 work.
Keep everything running
No action is needed. Choose this if you plan to execute the approved milestones after every recorded discrepancy has been reviewed.
- Keep sprint4-typed-simpro-client as the approved Sprint 4 work branch.
- Keep the implementation contract with its local preflight evidence.
- Start implementation only after every plan amendment has approval.
Pause - I'll come back to this later
No service needs to be stopped because the local mock is already stopped. Choose this while the implementation contract is waiting for review.
- Leave the local mock stopped.
- Keep sprint4-typed-simpro-client unchanged.
- Leave every pre-existing uncommitted or untracked file untouched.
- Resume from the first approved milestone after all discrepancies are explained.
Delete - I don't want to use this again
Removing a branch can feel risky. This option applies only after you confirm the branch contains no work worth keeping.
Your existing CLIVE-I checkout and all pre-existing local changes remain outside this cleanup.
- Confirm the Sprint 4 implementation plan is no longer needed.
- Confirm sprint4-typed-simpro-client contains no implementation work that needs preserving.
- Switch away from sprint4-typed-simpro-client in your Git client.
- Remove sprint4-typed-simpro-client through your team's reviewed local branch-removal workflow.
- Check your local branch list for sprint4-typed-simpro-client.
- Leave every pre-existing uncommitted or untracked file untouched.
You should no longer see sprint4-typed-simpro-client in the local branch list. No cloud service, database volume, or functional project file needs deletion.
Nice Work!
Nice Work!
You made it! CLIVE now has a repository-specific Sprint 4 implementation contract tied to commit 5c393e2ab8a5b92f64bdfaf497e13af4902847e9.
You've learned how to:
- Established a reproducible repository baseline at the reviewed commit. Defined a four-part change boundary. Protected completed Phase 2 storage. Preserved the Sprint 3 mock database.
- Mapped 12 typed resource contracts from the committed mock contract. Specified explicit Pydantic aliases for acronym-heavy fields. Assigned date to issued dates. Applied the same type to installed dates. Assigned datetime to created timestamps. Applied the same type to uploaded timestamps. Designed a generic endpoint core with lazy pagination. Preserved the Sprint 1 raw JSON methods.
- Defined a shared synchronous request policy with one client-owned token bucket. Kept the existing one-time 401 retry separate from bounded 429 retries. Preserved correlation context across retry attempts. Added quality gates for isolated unit tests. Added separate read-only integration checks against the local mock. Added lint checks with Ruff. Required an intentional commit with a clean working tree.
- Completed the Secret Mission by adding a non-destructive local preflight. It records the local branch. It records the working tree. It captures the target SHA. It checks collection with the mock stopped. It confirms Python 3.12.3. It preserves successful dependency synchronization evidence. Any discrepancy becomes a plan amendment before implementation.
Ready to quiz yourself?