SOP: Sending Contract Offers
SOP: Sending Contract Offers
Purpose
When a coach (Marcus) requests a new or updated contract offer for a player — whether it's a first contract, a re-offer after decline, or a tier change between teams — this SOP ensures the offer is minted via the committed API path, the parent is notified appropriately, and prior signed state is never silently wiped without explicit acknowledgement. Agent or human both use this SOP. Produces: a player in
contract_status='offered' with a valid token and a contract email in the parent's inbox, or a controlled tier-change with parent communication.Steps
- Identify the request precisely. Read the exact request from Marcus (WKQ Stakeholders GroupMe, DM, or session handoff). Name the player, the target team, the monthly fee, and the reason. If any of these four fields is ambiguous, stop and ask before proceeding.
- Check the player's current state. Query the Player by name to get: current
contract_status,contract_token,contract_signed_at, current team assignment(s), currentmonthly_fee. This is the baseline snapshot you'll compare against after.db.query(Player).filter(Player.name.ilike('%{name}%')).first() - Determine which transition applies. Match the current status against the four transitions handled by
offer_contract()inservices/contract_offers.py: - If transition is
signed → offered(tier change), STOP and confirm scope. This is the high-risk path. The code will archive the old signature intocontract_audit_logand wipecontract_signed_at,contract_signed_by,contract_signed_ip,contract_signature_url. Before executing, confirm with Lucas: - Execute via the API endpoint. Do not use ad-hoc SQL. Call
POST /admin/contract/offer(fromroutes/admin.py, backed byservices/contract_offers.offer_contract()) with the required parameters. For tier_change, passtarget_team_id. The endpoint will: - Verify the state change. Re-query the player. Confirm:
contract_status='offered', new token present, audit log entry created, team moved. If any of these is wrong, do NOT proceed to sending the email — investigate first. - Confirm the contract_offer email was sent. The endpoint auto-sends a contract_offer email to the parent. Query EmailLog to confirm:
EmailLog.email_type == contract_offer, sent_at within the last minute, recipient matches parent.email. If no email fired, check Gmail OAuth file-based token fallback. - Track it on the board. If this offer is part of a batch (e.g. Marcus's 2026-04-10 batch #424), update the batch tracker with the player's state + audit log entry id. If it's standalone, create a one-line ops note in the session memory.
- Follow up only if asked. Do not send additional "heads up" emails to the parent unless Marcus or Lucas explicitly approves. The contract_offer email is the canonical notification. Extra emails create confusion.
Rules
- NEVER revert a signed-to-offered state change without reading all open tickets referencing the player. A
signed → offeredtransition in the audit log is intentional code behavior when triggered via the admin API. Check issues tagged with the player's name, team, or parent email before deciding it's a bug. - ALWAYS check ContractAuditLog before "fixing" suspicious state. The audit log records the actor, timestamp, old_state, new_state, and event_type. If the actor is an admin email and the event_type is
tier_changeorre_offer, the change was intentional. - NEVER edit
contract_signed_*fields via raw SQL without a committed reason. If you need to change a player's team without wiping their signature (same fee, same program, operational roster adjustment), use raw SQL onplayer_teamsonly — leave theplayerstable signed fields alone — and record the decision incontract_audit_logwith a descriptiveevent_type(e.g.,team_move_no_resign). - NEVER mint a contract offer via ad-hoc SQL. Use
POST /admin/contract/offerso the audit log gets written and the contract_offer email auto-fires. Ad-hoc SQL bypasses both and leaves the system in an unauditable state. - ALWAYS communicate tier changes to the parent before the system email fires. A contract_offer email arriving out of the blue asks a parent who already signed to sign again, which is confusing. Marcus should text or call the parent first whenever possible.
- Same-fee tier changes should NOT require re-signing. If a player moves from Elite to Local (or vice versa) at the same monthly fee, consider whether the April 3 signature legally covers the program commitment. The current
offer_contract()code always wipes signatures on tier change — this is a known limitation, not a feature. Preserve the signature via rawplayer_teamsupdate + audit log entry instead. - NEVER send ad-hoc "apology" emails without Lucas approval. Two confusing emails + one contradictory apology is worse than one confusing email. If recovery requires a correction, draft it, read it back to Lucas, send only on explicit confirm.
- Contract emails are commitment signals, not legal instruments. Westside contracts express parent commitment to the program at a monthly rate. They do not need to be re-signed for every internal roster adjustment. When in doubt, preserve the original signature and adjust team assignment separately.
Recovery
Accidental tier_change
If an admin accidentally called
POST /admin/contract/offer with target_team_id on a signed player and wiped their signature:- Read
ContractAuditLogentry for the player. Theold_statefield contains the full signed snapshot (token, signed_at, signed_by, signed_ip, signature_url, team_ids, contract_version, monthly_fee). - If the change was truly accidental (not part of a Marcus batch): restore from
old_statevia raw SQL, write a newContractAuditLogentry withevent_type='revert_tier_change'documenting the revert. - If the parent already received the new contract_offer email, either (a) have Marcus call/text them to disregard it, or (b) leave it — they can ignore the email without consequence; the DB state is what matters.
- Do not send a system apology email unless explicitly instructed by Lucas. Human communication beats automated correction.
Signature-preserved team move
When Marcus wants to move a signed player to a different team at the same fee and same program (e.g., Elite → Local because the family doesn't want to travel), but you want to preserve their original signature:
- Do NOT call
POST /admin/contract/offer. That function always wipes signed fields on tier change. - Raw SQL:
DELETE FROM player_teams WHERE player_id = X;thenINSERT INTO player_teams (player_id, team_id) VALUES (X, Y); - Write an audit log entry:
event_type='team_move_no_resign', old_state and new_state snapshots, actor + source. - Flag this as technical debt — the
offer_contract()code should support this path natively.
Related
services/contract_offers.py— canonical implementation of the four transitions (code, not a note)template-validation— how to validate post-merge contract-offer code changessop-email-send— how Gmail OAuth sends work (file-based fallback, token store)sop-board-workflow— where contract-offer tickets live on the boardfeedback-never-edit-without-ticket— always ticket contract scope changes before executingsop-post-merge-docs— the right-side validation gate for contract-offer code changes