SOP: Sending Contract Offers

sop-contract-offer Sop

sop active

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), current monthly_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() in services/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 into contract_audit_log and wipe contract_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 (from routes/admin.py, backed by services/contract_offers.offer_contract()) with the required parameters. For tier_change, pass target_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 → offered transition 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_change or re_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 on player_teams only — leave the players table signed fields alone — and record the decision in contract_audit_log with a descriptive event_type (e.g., team_move_no_resign).
  • NEVER mint a contract offer via ad-hoc SQL. Use POST /admin/contract/offer so 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 raw player_teams update + 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 ContractAuditLog entry for the player. The old_state field 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_state via raw SQL, write a new ContractAuditLog entry with event_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; then INSERT 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.
  • services/contract_offers.py — canonical implementation of the four transitions (code, not a note)
  • template-validation — how to validate post-merge contract-offer code changes
  • sop-email-send — how Gmail OAuth sends work (file-based fallback, token store)
  • sop-board-workflow — where contract-offer tickets live on the board
  • feedback-never-edit-without-ticket — always ticket contract scope changes before executing
  • sop-post-merge-docs — the right-side validation gate for contract-offer code changes