Review: Add POST /admin/contract/offer endpoint
Review: Add POST /admin/contract/offer endpoint (board #931, basketball-api #425)
Verdict: NEEDS_REFINEMENT (minor). All file targets verified, scope is well-specified, dependencies real. Two small gaps before promotion: field-name drift and missing arch backing note. No decomposition needed.
Template completeness (template-issue-feature, 11 sections)
- Type — present (Feature)
- Lineage — present (standalone, 2026-04-10 Westside Ops, links to #424)
- Repo — present (forgejo_admin/basketball-api)
- User Story — present (Ava/admin agent minting contract offers)
- Context — present, unusually thorough; explains existing code state, the gap, and why now
- File Targets — present, with explicit do-not-touch list
- Acceptance Criteria — present, 14+ criteria covering request/response shape, all four state transitions, security, DB integrity, explicit non-goals
- Test Expectations — present, 9 unit tests + 1 integration + run command
- Constraints — present, covers patterns, token source, transaction boundaries, logging, audit-log scope
- Checklist — present, 9 items including post-merge ArgoCD sync + smoke test
- Related — present, links to #424, #422, #420, pal-e-deployments#104, sop-email-send, westside-email-agent
All 11 sections present. Template completeness: PASS.
Traceability triangle
- story:WS-S7 — story exists on project-westside-basketball (Admin: "As an admin, I want to send branded email announcements so that parent comms are professional and consistent"). Fit is indirect: this endpoint mints the offered state that the branded-email blast flow consumes. Defensible as a prerequisite enabler, but a more literal fit would be WS-S23 ("configure custom contract terms per player"). Recommend: add WS-S23 as a secondary story label, keep WS-S7 as primary.
- arch:basketball-api — label present on the board item, but no backing
arch-basketball-apinote exists in pal-e-docs (search_notes returns empty). [SCOPE] Create architecture note arch-basketball-api. This is a pre-existing gap across many basketball-api tickets, not unique to #931 — flag for later cleanup, do not block this ticket on it. - Forgejo issue — #425 open, body well-formed, matches board item title.
File targets — verified against fresh clone of main (commit 9598c4d)
src/basketball_api/routes/admin.py— EXISTS. Usesrequire_admin,Depends(get_db), importsplayer_teamsjunction, usessecrets.token_urlsafe(32)in/admin/generate-tokens(confirmed line 94). Convention target is sound.src/basketball_api/services/contract_offers.py— NEW file, absent as expected.tests/test_contract_offer.py— NEW file, absent as expected.alembic/versions/NNN_add_contract_audit_log.py— latest version on main is 021. New migration number will be 022 (verify no in-flight PRs with conflicting 022 before merge).src/basketball_api/services/email.py— EXISTS (do-not-touch).src/basketball_api/services/email_queries.py— EXISTS,query_unsigned_contractsdefined at line 31, registered in query dispatch at line 157 (do-not-touch).src/basketball_api/models.py— EXISTS. Verified all referenced Player fields:contract_status(line 254),contract_signed_at/by/ip(257-259),contract_token(260),contract_signature_url(263),contract_version(266),monthly_fee(267).
Field-name drift (fixable nit)
Ticket refers to the archived field as
signature_url in the signed→re-offer acceptance criterion. Actual model field is contract_signature_url. Also ticket says "old signed_ip, old signed_by" — actual fields are contract_signed_ip and contract_signed_by. Update ticket AC to use exact model field names so the implementing agent doesn't have to guess-and-grep.Dependencies — all real, all open
- #420 (Alice dedupe) — board #925, backlog, open. Correctly cited as blocking Alice's offer (otherwise token lands on ambiguous row).
- #422 (Create 16U Local Queens team) — board #927, backlog, open. Correctly cited as blocking Jacelyn's tier-change target.
- #424 (Marcus batch execution) — board #930, backlog, open. Downstream consumer; #425 unblocks it.
- pal-e-deployments #104 — cited as the larger ops_audit_log precursor. Acknowledged in ticket scope ("narrower slice"). Good.
Note: #420 and #422 must land before #425's implementation can be fully smoke-tested on prod, but #425 itself has no code-level dependency on them — the endpoint can be built and unit-tested independently. Recommend: clarify in ticket that #420/#422 are prod-smoke-test blockers, not PR-merge blockers.
Decomposition assessment (5-minute rule)
- 4 file targets (1 modify, 3 new) in 1 repo — borderline.
- 14+ acceptance criteria — exceeds the >5 heuristic, but they are tightly cohesive (request/response shape + four state transitions + security + integrity all describe one endpoint's behavior).
- 9 unit tests + 1 integration test — substantial.
- Realistic agent time: 15-25 minutes.
Recommendation: do NOT decompose. The signed→re-offer case is the novel piece and must ship atomically with the rest to avoid half-implemented state machines. Splitting by state transition would create partial endpoints that each leave the system in an inconsistent state. Splitting migration from endpoint is possible but creates a dangling table. The ticket correctly acknowledges its size, and the cohesion justifies the exception.
Signed → re-offer handling review
Well-specified. Validates:
- Requires
target_team_iddifferent from current (rejects otherwise with 422). - Archives old signed fields to
contract_audit_logwith full JSONB snapshot before clearing. - Clears player row's signed fields (prevents stale signature display).
- Mints new token.
- Updates
player_teamsjunction (remove old, add new). - All in a single transaction.
One gap: unclear whether the OLD
contract_token is preserved in the audit snapshot. Recommend adding "old contract_token" to the archived JSONB snapshot list — important for tracing back which signed URL was used if a parent disputes the tier change.Transaction boundaries review
Ticket specifies "single DB transaction — either the whole offer lands or none of it does" and "Transaction boundaries must be explicit (
with db.begin_nested() or equivalent)." Correct. One implementation nit: begin_nested() creates a SAVEPOINT, not a top-level transaction — the outer session handling (FastAPI's get_db pattern) should commit/rollback the outer transaction. Implementing agent should verify this matches the existing admin-endpoint pattern (check how /admin/generate-tokens handles commit).Acceptance criteria ambiguity check
All criteria are verifiable. Two minor items:
force=trueis documented as a query param for the offered→offered case but not listed in the request shape JSON. Clarify whetherforceis query-string or body field (query is fine, just say so once).- Response field
previous_signed_state_archived: false— for none→offered case, this is false. For signed→offered, true. For offered→offered with force, unclear (probably false since nothing signed exists to archive). Clarify.
Blast radius
No other basketball-api endpoint mints contract tokens. The only code that sets
contract_status='offered' today is the (uncommitted) ad-hoc SQL path — this ticket is the permanent replacement. No sibling services affected. Downstream: westside-contracts consumes the contract_token URL; ticket does not modify that contract. Good.Required refinements before promoting to todo
- Fix field names in the signed→re-offer AC: use
contract_signature_url,contract_signed_ip,contract_signed_by(exact model field names). - Add
contract_tokento the archived JSONB snapshot so old signed URLs remain traceable. - Clarify
forcelocation (query param vs body) and behavior ofprevious_signed_state_archivedresponse field across all four transitions. - Clarify dependency semantics: #420 and #422 are prod-smoke-test blockers, not PR-merge blockers. Endpoint code + unit tests can land independently.
- Add secondary story label story:WS-S23 (custom contract terms per player) alongside the existing story:WS-S7.
Deferred / separate scope
- [SCOPE] Create arch-basketball-api note — pre-existing gap, not unique to this ticket. File as a separate backlog item to clean up all basketball-api tickets' traceability triangles at once.
Verdict
NEEDS_REFINEMENT — 5 small fixable issues, no decomposition, no BLOCK-level problems. After the 5 refinements land in the issue body, this is ready to move backlog → todo.