27 KiB
Claude.md — JQC Developer Reference
Audience: AI assistants and developers working on this codebase.
Purpose: Authoritative reference for architecture, conventions, gotchas, and decisions.
Last reviewed: April 2026 (Phase 12 complete + post-review hardening patch)
Table of Contents
- Project Overview
- Tech Stack
- Repository Layout
- Environment & Configuration
- Database Models
- Role & Permission Matrix
- Blueprint Prefixes & Route Inventory
- Utility Modules
- Mobile API (Phase 7)
- Notification System
- SLA Engine
- Audit Trail
- PDF Export
- Scheduled Reports
- Rate Limiting
- Alembic Migration Chain
- Frontend Conventions
- Infrastructure
- Known Constraints & Hard Rules
- Change Philosophy
1. Project Overview
JQC (Janitorial Quality Control) is a production-grade, full-stack web application that manages:
- Janitorial service contracts organised as Contracts (Projects) → Facilities → Areas
- Inspection execution against configurable templates with dynamic form builder
- Issue tracking with SLA enforcement, follower subscriptions, and verification workflow
- Customer portal with scoped facility visibility and invitation-based onboarding
- Notification system (in-app + email) driven by an admin-controlled matrix
- Reports — on-demand PDF/CSV scorecards and scheduled email digests
- Audit trail — immutable log of every create/update/delete action
- Mobile API — JWT-authenticated REST layer (Phase 7) for a React Native / Expo mobile app
The application is actively deployed in production and maintained by a single developer/administrator.
2. Tech Stack
| Layer | Technology |
|---|---|
| Language | Python 3.11+ |
| Web framework | Flask (application factory pattern) |
| ORM | Flask-SQLAlchemy (SQLAlchemy 2.x) |
| Database | MySQL (via PyMySQL driver) |
| Auth (web) | Flask-Login + Flask-WTF CSRF |
| Auth (API) | JWT access tokens + opaque refresh tokens (PyJWT) |
| Rate limiting | Flask-Limiter (Redis-backed in production via REDIS_URL; falls back to in-process memory for dev) |
| Migrations | Flask-Migrate / Alembic |
| Flask-Mail (SMTP, background threading) | |
| PDF generation | ReportLab |
| Forms | WTForms + Flask-WTF |
| Templating | Jinja2 |
| Frontend | Bootstrap 5, Chart.js, vanilla JS |
| Server | Gunicorn (sync workers) behind Nginx |
| OS | Ubuntu Linux |
| Mobile client | React Native + Expo (Expo Go for dev; cloud Mac for iOS builds) |
| Timezone | All datetimes stored as US/Eastern (naive, via now_eastern()) |
3. Repository Layout
lt_janitorial_quality_control/
├── app/
│ ├── __init__.py # Application factory — limiter, csrf, db, mail, login_manager
│ ├── add_form_schema.py # One-off migration helper (safe re-run)
│ ├── api/ # Mobile REST API (Phase 7)
│ │ ├── __init__.py # api_bp parent blueprint + register_api()
│ │ ├── auth.py # /api/v1/auth/* and /api/v1/devices/*; rate-limited login/refresh
│ │ ├── decorators.py # @jwt_required
│ │ ├── errors.py # JSON error helpers + error handler registration
│ │ └── jwt_utils.py # generate_access_token()
│ ├── models/
│ │ ├── __init__.py
│ │ ├── api_token.py # RefreshToken, DeviceToken
│ │ ├── audit.py # AuditLog
│ │ ├── facility.py # Facility, Area
│ │ ├── inspection.py # InspectionTemplate, ChecklistItem, Inspection, InspectionResult
│ │ ├── issue.py # Issue, IssueComment, IssueFollower
│ │ ├── notification.py # Notification, NotificationPreference + event constants
│ │ ├── notification_matrix.py
│ │ ├── project.py # Project, CustomerAssignment
│ │ ├── scheduled_report.py # ScheduledReport
│ │ └── user.py # User (password-setup workflow, display_name)
│ ├── routes/
│ │ ├── auth.py # /auth/* — login rate-limited; _safe_next() open-redirect guard
│ │ ├── customers.py # /customers/* — _safe_referrer() helper; expired invitation banner
│ │ ├── dashboard.py # / — single open_issues query (len() not .count())
│ │ ├── facilities.py # /facilities/*
│ │ ├── inspections.py # /inspections/* — passes now=now_eastern() for stale badge
│ │ ├── issues.py # /issues/* — quick-assign AJAX; facility filter; SLA column
│ │ ├── notifications.py # /notifications/* — send-digest, check-sla, cleanup-tokens
│ │ ├── projects.py # /projects/*
│ │ ├── reports.py # /reports/*
│ │ ├── scheduled_reports.py # /scheduled-reports/*
│ │ ├── templates.py # /templates/*
│ │ └── audit.py # /audit/*
│ ├── static/
│ │ ├── css/ipad_responsive.css
│ │ └── uploads/
│ ├── templates/
│ │ ├── base.html # Active nav tab styling; request.endpoint-based active detection
│ │ ├── _sla_badge.html
│ │ └── ...
│ └── utils/
│ ├── audit.py # log_action() + ACTION_* constants
│ ├── decorators.py # @admin_required, @supervisor_required, @project_manager_required, @customer_required
│ ├── forms.py # All WTForms (AreaForm includes 'floor' type)
│ ├── notifications.py # notify(), notify_by_matrix(), send_pending_digests()
│ ├── pdf_export.py # ReportLab PDF generation
│ ├── scope.py # get_customer_scope()
│ ├── sla.py # sla_status(), sla_deadline(), sla_hours_remaining(), send_sla_alerts()
│ └── time_utils.py # now_eastern()
├── migrations/
│ └── versions/
│ ├── phase1_projects_roles.py
│ ├── phase6_features.py
│ ├── phase7_mobile_api.py
│ ├── phase8_notification_matrix.py
│ ├── phase9_user_full_name.py
│ ├── phase10_customer_password_setup.py
│ ├── phase11_director_role.py
│ └── phase12_performance_indexes.py ← HEAD
├── config.py
├── gunicorn_config.py
├── requirements.txt # Includes Flask-Limiter
├── run.py
└── wsgi.py
4. Environment & Configuration
Required Environment Variables
| Variable | Notes |
|---|---|
SECRET_KEY |
Flask secret — no fallback; startup fails if absent |
DATABASE_URL |
e.g. mysql+pymysql://user:pass@localhost/jqc |
MAIL_SERVER |
SMTP hostname |
MAIL_USERNAME |
SMTP login |
MAIL_PASSWORD |
SMTP password |
MAIL_PORT |
465 (SSL) or 587 (STARTTLS) — auto-selects flags |
APP_BASE_URL |
Full URL for email links |
MAIL_DEFAULT_SENDER |
From address |
DIGEST_SECRET |
Authenticates all cron endpoints: send-digest, check-sla, cleanup-tokens, scheduled-reports/run |
REDIS_URL |
Optional. e.g. redis://127.0.0.1:6379/0. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. Falls back to in-process memory if absent. |
Email SSL Auto-Detection
MAIL_USE_SSL = _mail_port == 465
MAIL_USE_TLS = not MAIL_USE_SSL
Critical: Never set both to True — Flask-Mail breaks silently.
File Uploads
UPLOAD_FOLDER=app/static/uploads/MAX_CONTENT_LENGTH= 50 MB- Allowed:
png,jpg,jpeg,gif
5. Database Models
User
users: id, username (unique, indexed), full_name, email (unique, indexed),
password_hash, role (ENUM), created_at, active,
password_set, set_password_token (indexed), set_password_token_expires
Role ENUM: admin, director, inspector, project_manager, customer
Key property: display_name → full_name.strip() or falls back to username. Use this everywhere in templates — never use .username for display.
Expired invitations: Customer accounts with password_set=False and set_password_token_expires < now_eastern() are flagged on the /customers index with an inline Resend button.
Token verification: User.verify_set_password_token() uses hmac.compare_digest() as a constant-time comparison guard after the DB lookup and expiry check, preventing timing oracle attacks on the stored token value.
Facility / Area
facilities: id, name, address, contact_person, contact_phone, active, project_id (FK)
areas: id, facility_id (FK), name, area_type
area_type choices: restroom, lobby, hallway, office, kitchen, storage, floor, outdoor, other
Project / CustomerAssignment
projects: id, name, description, project_manager_id, active, created_at
customer_assignments: id, user_id, project_id, facility_id (nullable)
UniqueConstraint(user_id, project_id, facility_id)
UI terminology: "Project" is displayed as "Contract" throughout the UI. All backend identifiers (project_id, class Project, routes projects.*) are unchanged.
facility_id = NULL → access to all active facilities in that project.
Inspection
inspections: id, template_id, facility_id, area_id, inspector_id, inspection_date,
overall_score, status (in_progress/completed/flagged), notes, form_data (JSON),
completed_at, parent_inspection_id (self-FK), follow_up_required, follow_up_note
Indexed columns (Phase 12): status, facility_id, inspector_id, inspection_date
Score rule: Items with score = 0 mean "unanswered" — excluded from calculation entirely.
Status display: completed renders as "Submitted" in the UI. DB value unchanged.
Stale badge: In-progress inspections older than 24 hours show a ⚠ Stale badge. Route passes now=now_eastern() to the list template.
Issue
issues: id, inspection_id (nullable), area_id, severity (low/medium/high/critical),
description, photo_path, status (open/in_progress/resolved/pending_verification),
assigned_to, reported_at, resolved_at, result_notes, result_photos (JSON),
verified_by, verified_at, verification_note, sla_notified
Indexed columns (Phase 12): status, severity, assigned_to, reported_at
Issue status flow: open → in_progress → pending_verification → resolved
Quick-assign: POST /issues/<id>/quick-assign — AJAX endpoint for admin/director. Returns JSON. Sends assignment notification. Logs to audit trail.
Assignable staff roles: admin, director, inspector — all three appear in the assignment dropdown on the issue view, create, and list quick-assign forms. admin was previously absent from the view/create dropdowns; corrected.
Notification / NotificationPreference
notifications: id, user_id, title, body, link, is_read, created_at, issue_id, event_type
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
Pause all emails: Preferences page has a "Pause All Emails" toggle that bulk-disables all email toggles client-side.
NotificationMatrix
notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
UniqueConstraint(event_type, role_key)
AuditLog
audit_logs: id, user_id (nullable, SET NULL on delete), username (snapshot), user_role (snapshot),
action, entity_type, entity_id, entity_label, details, created_at (indexed), ip_address
RefreshToken / DeviceToken
api_refresh_tokens: id, user_id, token_hash (SHA-256, unique), device_id, device_name,
created_at, expires_at, revoked
api_device_tokens: id, user_id, device_id, apns_token, device_name, app_version, registered_at
UniqueConstraint(user_id, device_id)
Token cleanup:
- Passive — on every successful API login, expired/revoked tokens for that user are deleted
- Cron —
POST /notifications/cleanup-tokens?token=<DIGEST_SECRET>deletes all globally
6. Role & Permission Matrix
| Area | admin | director | project_manager | inspector | customer |
|---|---|---|---|---|---|
| Dashboard | ✅ full | ✅ full | ✅ full | ✅ limited | ✅ scoped |
| Users | ✅ | ✅ | ❌ | ❌ | ❌ |
| Notification Matrix | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Customers | ✅ | ✅ | ❌ | ❌ | ❌ |
| Facilities | ✅ | ✅ | ✅ | read | scoped |
| Contracts | ✅ | ✅ | ✅ | read | scoped |
| Templates | ✅ | ✅ | ❌ | ❌ | ❌ |
| Inspections (execute) | ✅ | ✅ | ✅ | ✅ | read |
| Issues (create/assign) | ✅ | ✅ | ✅ | ✅ | read |
| Issues (quick-assign) | ✅ | ✅ | ❌ | ❌ | ❌ |
| Issue verification | ✅ | ✅ | ❌ | ❌ | ❌ |
| Reports | ✅ | ✅ | ✅ | ✅ | scoped |
| Scheduled Reports | ✅ | ✅ | ✅ | ❌ | ❌ |
| Audit Trail | ✅ only | ❌ | ❌ | ❌ | ❌ |
| Mobile API | ✅ | ✅ | ✅ | ✅ | ❌ |
Decorator Map
@admin_required # role == 'admin' only
@supervisor_required # role in ('admin', 'director') — name kept to avoid touching 30+ routes
@project_manager_required # role in ('admin', 'director', 'project_manager')
@customer_required # role == 'customer' only
7. Blueprint Prefixes & Route Inventory
| Blueprint | Prefix | Notable routes |
|---|---|---|
auth |
/auth |
/login (rate-limited 20/min), /logout, /profile, /users/*, /notification-matrix |
dashboard |
/ |
GET /, /facility-trend (AJAX) |
facilities |
/facilities |
CRUD + area management |
projects |
/projects |
CRUD + customer assignment management |
customers |
/customers |
list (expired invitation banner), invite, set-password, manage, import CSV |
inspections |
/inspections |
list (stale badge, now passed from route), start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect |
templates |
/templates |
list, create, edit, delete, form editor, preview |
issues |
/issues |
list (SLA column, facility filter, quick-assign dropdown), view, create, update, verify, comment, follow/unfollow, verification queue, delete |
issues |
/issues |
POST /<id>/quick-assign — JSON AJAX, admin/director only |
notifications |
/notifications |
list, mark-read, mark-all-read, preferences (pause-all toggle), send-digest (cron), check-sla (cron), cleanup-tokens (cron) |
audit |
/audit |
list (admin only), view, purge |
reports |
/reports |
index, facility report, scorecard, CSV/PDF export |
scheduled_reports |
/scheduled-reports |
CRUD + manual trigger |
api |
/api/v1 |
parent blueprint |
api_auth |
/api/v1 |
/auth/login (10/min), /auth/refresh (30/min), /auth/logout, /auth/me, /devices/register |
8. Utility Modules
time_utils.py
now_eastern() — always use this, never datetime.utcnow().
audit.py
log_action(action, entity_type, entity_id, entity_label, details) — call after db.session.commit().
scope.py
get_customer_scope(user) — returns list[int] facility IDs for customers, None for staff. Uses a single bulk Facility.project_id.in_(...) query for project-scoped assignments — never N+1.
forms.py
All WTForms classes. AreaForm.area_type includes floor. UserForm excludes customer role.
notifications.py
notify(), notify_by_matrix(), notify_customers_for_facility() — all email sent in background thread.
sla.py
sla_status(issue) → 'ok' | 'at_risk' | 'breached' | None (resolved).
send_sla_alerts() — called by cron, deduplicates via issue.sla_notified.
pdf_export.py
ReportLab-based. 12-column grid must be preserved — never collapse in PDF views.
9. Mobile API (Phase 7)
Auth Flow
POST /api/v1/auth/login→ access token (60 min JWT) + refresh token (30 day opaque hex)- Bearer token on every request
POST /api/v1/auth/refresh→ token rotation (old revoked, new issued)POST /api/v1/auth/logout→ revokes refresh token
Rate Limits
| Endpoint | Limit |
|---|---|
POST /api/v1/auth/login |
10/min, 3/sec |
POST /api/v1/auth/refresh |
30/min, 5/sec |
POST /auth/login (web) |
20/min, 5/sec |
Refresh Token Cleanup
- Raw token never stored — SHA-256 hash only
- Passive: expired/revoked tokens purged per-user on each login
- Cron:
POST /notifications/cleanup-tokens
CSRF Exemption Pattern
csrf.exempt(api_bp) # BEFORE register_api(app) — order matters
register_api(app)
Phase 2 (planned)
api_facilities, api_inspections, api_issues, api_notifications, api_photos — all stubbed in app/api/__init__.py.
10. Notification System
Event Constants (app/models/notification.py)
inspection_completed, issue_assigned, issue_reassigned, issue_unassigned,
issue_status, issue_comment, issue_follow_update, issue_flagged, issue_created,
issue_updated_customer, verification_requested, sla_alert,
customer_inspection_completed
Cron Endpoints (all require token=DIGEST_SECRET)
| Endpoint | Purpose | Schedule |
|---|---|---|
POST /notifications/send-digest |
Digest email delivery | 0 7 * * * |
POST /notifications/check-sla |
SLA breach/at-risk alerts | */30 * * * * |
POST /notifications/cleanup-tokens |
Purge expired API tokens | 0 3 * * * |
Implicit Notifications (always sent, not matrix-controlled)
- Issue assignee on assignment/status/comment
- Issue followers on any update
- SLA assignee + followers on SLA events
11. SLA Engine
| Severity | Window | At-Risk |
|---|---|---|
| critical | 4h | 3h |
| high | 24h | 18h |
| medium | 72h | 54h |
| low | 168h | 126h |
SLA column shown on issues list. issue.sla_notified prevents duplicate cron notifications.
SLA filter on issues list: When ?sla= is active, the full matching result set is loaded and filtered in Python, then wrapped in _SLAFilteredPage (defined at the top of routes/issues.py). This satisfies the template's pagination interface (.items, .page, .pages, .iter_pages()) without any template changes. Pagination nav is automatically suppressed via the existing {% if issues.pages > 1 %} guard.
12. Audit Trail
- Admin-only at
/audit/— director is excluded - Actions:
CREATE,UPDATE,DELETE,LOGIN,LOGOUT,EXPORT - Immutable — never updated or deleted through the application (purge UI exists for old records)
- IP from
X-Forwarded-Fororrequest.remote_addr
ACTION_EXPORT coverage: Both /reports/export/inspections and /reports/export/issues call log_action(ACTION_EXPORT, ...) before streaming the CSV response.
Logging coverage: All route modules now import logging and define a module-level logger = logging.getLogger(__name__). Previously missing from facilities.py, templates.py, reports.py, and dashboard.py.
13. PDF Export
ReportLab — app/utils/pdf_export.py. 12-column grid must be preserved — do not collapse in print/PDF.
14. Scheduled Reports
Types: summary, facility, issues. Frequencies: daily, weekly, monthly.
Cron: POST /scheduled-reports/run?secret=<DIGEST_SECRET>
15. Rate Limiting
Initialised in app/__init__.py as a module-level extension:
limiter = Limiter(
key_func = get_remote_address,
default_limits = [],
storage_uri = os.environ.get('REDIS_URL', 'memory://'),
)
limiter.init_app(app)
Import in routes: from app import limiter, then @limiter.limit('N per period').
Production: Set REDIS_URL=redis://127.0.0.1:6379/0 in the server environment. Counters are then shared across all Gunicorn workers and rate limits are correctly enforced.
Development: REDIS_URL absent → falls back to memory:// (in-process, single-worker). No Redis install required locally.
redispackage is inrequirements.txt. Install it withpip install -r requirements.txtbefore settingREDIS_URL.
16. Alembic Migration Chain
phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notification_matrix
→ phase9_user_full_name → phase10_customer_password_setup → phase11_director_role
→ phase12_performance_indexes ← HEAD
MySQL ENUM Change Protocol (3 steps — always follow)
-- 1. Expand to include both values
ALTER TABLE users MODIFY COLUMN role ENUM('admin','supervisor','director',...) NOT NULL;
-- 2. Migrate data
UPDATE users SET role = 'director' WHERE role = 'supervisor';
-- 3. Contract to remove old value
ALTER TABLE users MODIFY COLUMN role ENUM('admin','director',...) NOT NULL;
MySQL Compatibility
CREATE INDEX IF NOT EXISTS requires MySQL ≥ 8.0.1. For 5.7 compatibility use information_schema.statistics existence checks — see phase12_performance_indexes.py for the reusable _index_exists() pattern.
Deprecated SQLAlchemy Patterns
# WRONG
Model.query.get(id)
Model.query.get_or_404(id)
# CORRECT
obj = db.session.get(Model, id)
if obj is None: abort(404)
All
Model.query.get()calls have been replaced — includingload_user()inmodels/user.pyand three calls inutils/notifications.py.
17. Frontend Conventions
Active Nav Tab
/* base.html <style> block */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255,255,255,0.18);
color: #fff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
Active state detected via request.endpoint.startswith('<blueprint>.') in each nav <a> tag.
Display Names
Always use user.display_name in templates — never .username for display purposes.
Status Label Map
| DB value | Displayed as |
|---|---|
completed |
Submitted |
in_progress |
In Progress |
flagged |
Flagged |
open |
Open |
resolved |
Resolved |
pending_verification |
Pending Verification |
Forms
- Flask-WTF CSRF auto-applied to all web forms
- Manual forms:
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> - Never nest
<form>tags — browsers silently discard inner forms
Real-Time
SSE banned. All "live" updates use polling.
18. Infrastructure
Gunicorn
bind = "127.0.0.1:8000"
workers = multiprocessing.cpu_count() * 2 + 1
worker_class = "sync"
timeout = 30
Logs: /home/jqc/logs/gunicorn-error.log, /home/jqc/logs/gunicorn-access.log
Application Logging
RotatingFileHandler→logs/jqc.log(5 × 5 MB)StreamHandler→ stdout (journalctl)- Format:
[YYYY-MM-DD HH:MM:SS] LEVEL in module: message
Nginx
client_max_body_size 50M- Passes
X-Forwarded-For
Recommended Cron Schedule
0 7 * * * curl -s -X POST https://your-domain.com/notifications/send-digest \
-d "token=SECRET&frequency=daily"
*/30 * * * * curl -s -X POST https://your-domain.com/notifications/check-sla \
-d "token=SECRET"
0 3 * * * curl -s -X POST https://your-domain.com/notifications/cleanup-tokens \
-d "token=SECRET"
0 8 * * * curl -s -X POST https://your-domain.com/scheduled-reports/run \
-d "secret=SECRET"
19. Known Constraints & Hard Rules
| # | Rule | Rationale |
|---|---|---|
| 1 | No SSE | Exhausted Gunicorn sync worker pool |
| 2 | now_eastern() always |
utcnow() caused incorrect SLA cutoffs |
| 3 | 3-step MySQL ENUM changes | Skipping causes data loss |
| 4 | Port 465 → SSL; 587 → STARTTLS | Both True breaks Flask-Mail |
| 5 | CSRFProtect as extension instance |
Flask-WTF 1.0+ removed per-route @csrf_exempt |
| 6 | supervisor_required name preserved |
Renaming would touch 30+ route decorators |
| 7 | Score 0 = unanswered | Excluded from calculation — not the same as scoring zero |
| 8 | 12-column grid in PDF | Must not collapse in print/PDF |
| 9 | No nested <form> tags |
Browsers silently discard inner forms |
| 10 | log_action() after db.session.commit() |
Entity ID must exist before audit capture |
| 11 | db.session.get(Model, id) not Model.query.get(id) |
SQLAlchemy 2.x deprecation |
| 12 | filter() before limit() |
SQLAlchemy ordering requirement |
| 13 | Bulk queries in customer list | Per-customer loops cause N+1 |
| 14 | Email in background thread | Never block HTTP response |
| 15 | Open-redirect guards | _safe_next() in auth.py; _safe_referrer() in customers.py |
| 16 | CREATE INDEX IF NOT EXISTS not on MySQL < 8.0.1 |
Use information_schema — see phase12 |
| 17 | Set REDIS_URL in production |
memory:// is per-process; multi-worker Gunicorn needs Redis for accurate shared counters |
| 18 | "Project" → "Contract" is UI-only | Backend identifiers unchanged |
| 19 | display_name not username in templates |
Respects full_name; username is login identity only |
| 20 | open_issues count derived from len() not .count() |
Avoids a second DB round-trip on the dashboard |
| 21 | supervisor removed from Python-side ENUM |
Phase 11 migration complete — both DB and model ENUM must stay in sync |
| 22 | SLA filter loads full result set (no paginate) | SLA status is computed in Python; _SLAFilteredPage wrapper satisfies the template pagination interface |
| 23 | hmac.compare_digest() for token comparison |
Prevents timing oracle attacks on verify_set_password_token |
| 24 | get_customer_scope() uses bulk project query |
Single Facility.project_id.in_(project_ids) replaces per-assignment loop |
| 25 | CSV exports always call log_action(ACTION_EXPORT, ...) |
Data exports are compliance-relevant audit events |
20. Change Philosophy
- Surgical, additive patches — smallest possible change to achieve the goal
- Preserve all routes, function names, variable names unless explicitly directed otherwise
- Never remove existing functionality unless explicitly directed
- Log all create/update/delete actions via
log_action() - Migration existence checks — all migrations safe to re-run
- Full file contents for 1–3 file changes; deployment map for larger changesets
- Explicit deploy instructions — migration steps separated from code steps
- Root cause analysis on errors — never apply temporary workarounds