# PRD — ipw-php

Evidence date: 2026-10-11. Source: `git@bitbucket.org:overdrive/ipw-php.git`, `main` at `054883d9` (2026-09-28).

There is no prior PRD in Silicon Ark. The repository has a README, scope notes, and a `knowledgeBase/` folder. Requirements below are taken from the CodeIgniter 4 implementation and from notes that match that implementation. Where an older note describes `livesite/` paths or rules that the current code does not show, the conflict is stated instead of being copied as a requirement.

Provenance labels: **Documented**, **Implemented**, **Inferred**, **Planned**, **Unknown**.

## Product purpose

Members of the IPW scheme (farms, cellars, bottlers, and related producer groups) complete evaluation forms. IPW staff review those forms, score IPW audits, record SEV audits, track SAWIS compliance, manage probation, issue certificate PDFs, and publish public guidelines and literature.

Provenance: **Documented** (`README.md`, `knowledgeBase/Overview.md` title) and **Implemented** (controllers listed in the workflows below).

The Composer description “Transactflow” and the generic `overview.md` are starter leftovers. They are not the product purpose.

## Users and roles

Two user classes are documented in `knowledgeBase/RBAC_Implementation.md` and used in code (`ScoringRules::MEMBER_USER_CLASS_ID = 2`):

| Class | `user_class_id` | What the code does with it | Provenance |
|---|---|---|---|
| Staff / admin | 1 | Not limited to one SAWIS group. Staff scoring on an IPW audit ignores member `disable_options` and may use scores 0–5 | Documented and implemented |
| Member | 2 | Scoped to the selected member group. Listings and certificate downloads that lack a selected group return nothing | Documented and implemented (`MemberEvaluations`, `Certificates::resolveCertificateScope`) |

Coarse `user_role` names inserted by `app/Database/Seeds/UserRole.php` if missing: Developer, Administrator, BWI Member, Member, Public User, Auditor, Lead Auditor. Provenance: **Implemented** seed. The seed does not assign numeric ids.

Fine-grained `rbac_roles.name` values used by route-policy grants (`app/Config/RoutePolicyGrants.php`):

| Role name | Grants this matrix records | Provenance |
|---|---|---|
| System Developer | Staff core, including users, members, evaluations, SAWIS compliance, probation, reports, audit list, content. Also the `all_actions` / `all_objects` wildcard described as developer break-glass | Implemented in config |
| IPW Administrator | Same staff core as System Developer in this matrix | Implemented in config |
| IPW Data Capturer | Edit members and content; view member evaluations, SAWIS compliance, and auditor area | Implemented in config |
| IPW Auditor | View member evaluations, auditor area, and reports | Implemented in config |
| IPW Lead Auditor | Edit and delete members; view and edit member evaluations; lead-auditor object; edit audit list; view compliance and reports | Implemented in config |

`knowledgeBase/RBAC_Implementation.md` also lists IPW Content Manager, Member Reports, and member roles Edit Evaluation Forms, View Evaluation Forms, Member Administrator, and View Status Only, with importance numbers. Those names and importance values are **Documented**. They are not all repeated in `RoutePolicyGrants`. `RbacGrantReconciliation` excludes fine roles IPW Auditor, IPW Content Manager, and Member Reports from one reconciliation matrix, and excludes coarse role ids 3 and 4. Whether production still uses every documented role id is **Unknown / Requires Confirmation**.

`knowledgeBase/ProbationManagement.md` says probation read is `view/probation_view` and update is `edit/probation_view`, granted to System Developer and IPW Administrator. Data Capturer, Auditor, Lead Auditor, and member roles must not receive it from grant sync. Provenance: **Documented** and consistent with the probation section of the route-policy matrix (probation edit is on the staff-core list, not on Auditor, Data Capturer, or Lead Auditor).

Anonymous visitors can open the public content routes in `app/Config/PublicRoutes.php`. Certified-farm routes stay staff-only. Provenance: **Implemented**.

BWI member behaviour is documented against legacy `livesite/bwi/`. A BWI Member role name is seeded. Current BWI screens were not traced in this review. **Unknown / Requires Confirmation** whether a separate BWI area still exists in the CI4 UI.

## Main workflows

### W1 — Public content and sign-in

Trigger: a person opens the site without a session.

Implemented behaviour:

- `/` renders `Pages::home`.
- Login is `/login` and `POST GetLogin` (`Welcome`).
- Registration is `register`. Pending registrations can be accepted or declined by staff (`users/acceptPendingMember`, `users/declinePendingMember`).
- Password reset is `forgot-password`, `reset-password`, `sendResetPasswordLink`, and `resetPassword`.
- Public pages include cellars, about-us, guidelines, manuals, codings, forms, literature, audit, contacts, courses, climate-change, sustainability, links, and contact-us.
- Old `*.php` bookmarks hit `LegacyRedirect` and a branded not-found view.
- Language switch is `language/switch`.
- Two-factor routes are commented out in `Routes.php` with the note that MFA is skipped.
- Maintenance mode runs before login (`MaintenanceMode` filter).

Provenance: **Implemented**.

### W2 — Select a member group

Trigger: a member who belongs to more than one producer group works in the member area.

Implemented behaviour:

- `POST api/user/set-active-group`, `clear-active-group`, `get-active-group`, and `get-user-groups`.
- Member evaluation lists, submission reads, and certificate downloads are restricted to the selected group. No selected group means no rows, not every group.
- Staff are not restricted that way on those endpoints.

Provenance: **Implemented**. Documented member multi-profile behaviour in the legacy overview matches this idea (`setusergroup`) but the URL is the CI4 API above.

### W3 — Self-assessment

Trigger: a member opens an evaluation for their group.

Application type `0` is labelled Self-assessment (`MemberEvaluations::getApplicationTypeText`).

Implemented behaviour:

- Editable statuses for scoring policy are `0` (New) and `10` (In Progress) (`ScoringRules::isEditableStatus`).
- Members on legacy-scheme forms may only use scores 5, 3, 2, or 0, subject to `disable_options`. Modern-scheme forms allow 0–5, and score 0 stays available even when other options are disabled.
- A main item whose legacy `disable_options` is `1111` is fully locked for members and must not block Submit as a required question.
- Appendix choices are Yes (1), No (2), or N/A (3), also subject to disable bits, except in the staff IPW-audit context.
- Submit moves work toward status `20` (Submitted). The full status catalogue is in business rules.
- January 2025 scope says submit should lock the producer copy and create an auditor copy. No copy-on-submit method was found in `MemberEvaluations`. Provenance of the three type labels: **Implemented**. Provenance of automatic copy: **Planned** in `scope/IPW_Audit_Platform_Scope.md`, not confirmed in the controller search.

### W4 — IPW audit (scored)

Trigger: staff open a submission with application type `1`.

Implemented behaviour:

- Badge label “IPW Audit”.
- Staff in that context (user class not member) may score 0, 1, 2, 3, 4, or 5 even on legacy forms, and they ignore `disable_options`.
- A Lead Auditor may submit an IPW audit with blank or partial answers. Unanswered items score 0. Audit Pass / Audit Fail still apply (`staff_incomplete_ipw_audit_submit_allowed`, `README.md`).
- Lead Auditor may override appendix parent totals (`staff_appendix_parent_override_allowed`). The README says the override also recognises legacy `user_role` 7 or fine-grained `view/ipw_auditor_lead`. The helper that was read checks `is_ipw_lead_auditor_user()` only. **Requires Confirmation** if role 7 without that helper still works.
- Ordinary auditors stay excluded from that override. Provenance: **Documented** in the README and **Implemented** in the helper for the Lead Auditor check.

### W5 — SEV audit

Trigger: staff work a submission with application type `2`, labelled “SEV Audit”.

Implemented behaviour found in services:

- `PassComplianceWriter` does not write SAWIS compliance for application type 2.
- `ScoreRecalculationPolicy` treats type 2 differently from scored types (recalculation is not the scored path).
- Probation close on pass does not treat SEV type 2 as the pass that closes probation (`ProbationLifecycle` comments).
- `AGENT_PLAN.md` records a fixed defect: SEV submit called `updateStatus(20)` then `calculateAndDetermineStatus` for application type 2, and the score update was rolled back when an audit-log insert was not required. The plan’s tasks are checked. The plan says notes-column migration and appendix parent autosave are out of scope for that fix.

A separate status code `40` is still described as “SEV” by `getStatusDescription`, while the badge helper `getStatusText` has no case 40. Provenance: **Implemented** split between application type 2 and status 40. Which one new audits must use is **Unknown / Requires Confirmation**. The January 2025 scope said SEV existed only as status 40 and that a full SEV type was still to be built. The type now exists in code.

### W6 — Score, Table B, and outcome

Trigger: answers are saved or staff recalculate.

Implemented behaviour (`ScoringRules`, `CellarTypes`, `SubmissionScoreRollup`, README):

- Farm (`form_type` 1) passing score is 70% of the applicable total, stored with half-up integer rounding. `285 * 0.70 = 199.5`, which rounds to 200. The README and commit `071ad4c` also describe a compact display of 199/285 (70%). **Requires Confirmation** which figure the screen shows.
- Optional farm Table B (conservation / natural area) that contributes a positive score switches the displayed maximum from the base total (example 285) to the full total (example 319) and the higher pass mark (example 223). A zero-point Table B stays saved but does not unlock 319/223.
- Cellar types A, B, and C use 65% of the applicable total. Types D and E use the fixed legacy pair 96 / 160 (60%) and are not derived from 65%.
- Cellar type labels in `CellarTypes`: A wine and bottle on site (value 0), B wine only (1), C wine and bottle off site (2), D bottlers plus own wine (3), E continues in that file. Greyed question numbers are configured per type.
- Forms with id 1–38, or an explicit `scoring_scheme` of `legacy`, use the legacy score set. New forms default to `modern`.
- Non-empty notes add 2 note points; empty notes add 0.
- Parent totals persist to one decimal place. Submission score persists as an integer.

Status labels used on screen and in print are not identical. See business rules.

### W7 — Probation

Trigger: an IPW audit reaches Audit Fail (`65`), or a later pass closes the row, or the daily command runs.

The lifecycle in `knowledgeBase/ProbationManagement.md` matches `App\Services\ProbationLifecycle` and is the requirement to test against. Summary:

- Only the first audit fail (`65`) creates probation. A self-assessment fail (`60`) does not.
- The active row lasts six calendar months. One row per evaluation.
- A later fail on an already expired row moves it to history and leaves the form at `65`.
- Pass (`50` or `55`) except SEV type 2 closes the active row. An IPW audit pass looks up the sibling form (same group and eval form, status not 90, latest application date).
- Staff may change the end date and move a row to history. Staff cannot create, hard-delete, or point the row at another evaluation.
- `php spark probation:process` expires rows after the end date (the end date itself is still active). With an attachment, the row becomes history. Without an attachment, SAWIS compliance for that case is deleted, the form becomes `60`, and the row becomes history.
- Reminders go to Member Administrator role id 14 only, and do not set `auto_processed`.

Provenance: **Documented** in that knowledge-base page and **Implemented** in the service it names. Whether production Task Scheduler runs the command is **Unknown / Requires Confirmation**.

### W8 — SAWIS identity, compliance, and certificates

Trigger: staff maintain a group, or a scored pass should show compliance, or someone downloads a certificate.

Implemented behaviour:

- Groups carry SAWIS numbers. Staff can sync group SAWIS data, set the active number, add or delete a number, merge groups, transfer a number, and roll a transfer back (`Users` routes, `SawisNumberTransferService`, `SawisFarmSyncService`, `spark sawis` command `SyncSawisFarms`).
- Group History “Farm/Cellar SAWIS Compliance” badges read `sawis_compliance` where `swsa_outcome = 1` means Yes for that group and year. `group_audit` types 50 and 51 are used only when no compliance rows exist (`README.md`).
- `group_audit` types 60 and 61 are suspend / un-suspend, not farm assessment audits (`GroupAuditModel::EXCLUDED_AUDIT_TYPE_IDS`).
- Certificate PDFs are generated with mPDF (`Certificates`). The README says they follow the legacy layout: bilingual wording, assessment issue dates, and farm number / farm name from SAWIS `FARM_MASTER`.
- Members only receive certificates for the selected group, and only if they may view member evaluations or view status only.

Provenance: **Implemented**, with the badge rule also **Documented** in the README.

### W9 — Documents

Trigger: a member or staff member uploads or downloads an audit document.

Implemented behaviour (`README.md`, `DocumentManagement`):

- Download route `document_uploads/download/{uuid}`.
- Files are stored under `public/assets/content/audit/<year>/<month>/`.
- Paths resolve from `FCPATH` so a web root of `public/` does not become `public/public/`.
- PDFs are streamed inline. The original file name must not be passed as the second argument of CI4 `download()`, because that argument is file contents.
- README operational note: directory must be writable, `fileinfo` improves MIME detection, and PHP upload limits should be at least 10M.

`knowledgeBase/BusinessRules.md` also says virus scanning, encryption at rest, a 50MB form total, and asynchronous processing. Those controls were not confirmed in the upload path reviewed via the README. Provenance of the path and download rules: **Documented** and **Implemented**. Provenance of virus scan and encryption: **Unknown** (stated in an unverified knowledge-base page only).

### W10 — Reports, audit list, lab, SO2

Trigger: a permitted user opens the corresponding menu.

Controllers exist and are routed: `Audit`, `Reports`, `Admin\ReportsController`, `LabAnalysis`, `So2Reports`, `Compliance`, `Probation`. The README describes audit-list CSV and Excel export with an optional audit-date range, live row count, and exact farm matching, using the olive primary colour `#6b8e23`. SO2 access is gated by `App\Services\So2Access`. The legacy BIS note says SO2 data is read from BIS `IPWSO2` for cellar members. Current SO2 reads go through `BisService` when the API or ODBC path is used.

Provenance: **Implemented** controllers. The export behaviour in the README is **Documented**.

## Functional requirements

### FR-001 — Authenticate before private pages

The system requires a logged-in session for every route except `PublicRoutes` patterns. Failed authorisation uses the menu grant filter and the route-policy filter.

Evidence: `app/Config/Filters.php`, `PublicRoutes.php`, `Authentication`, `Authorization`, `RoutePolicy`.

Implementation: Implemented. Confidence: High.

### FR-002 — Do not cache authenticated responses

Responses send no-cache headers so browsers, proxies, and CDNs do not store per-user or per-group pages.

Evidence: `NoCacheHeaders` filter, comment dated 2026-08-26 in `Filters.php`.

Implementation: Implemented. Confidence: High.

### FR-003 — Keep member data inside the selected group

Member calls that list submissions, load one submission, or download certificates must filter by the selected group. Missing group context returns an empty result.

Evidence: `MemberEvaluations::getEvaluationFormSubmissions`, `getAuthorizedSubmission`, `Certificates::resolveCertificateScope`.

Implementation: Implemented. Confidence: High.

### FR-004 — Score farms at 70% and cellar A/B/C at 65%

Passing scores are derived as in W6. Cellar D/E stay on 96/160. Legacy forms 1–38 keep the legacy score set unless `scoring_scheme` says otherwise.

Evidence: `ScoringRules.php`.

Implementation: Implemented. Confidence: High. The 199 versus 200 display is Requires Confirmation.

### FR-005 — Unlock the higher farm total only when Table B scores above zero

A positive completed or manually overridden Table B uses `total_score_w` / `passing_score_w`. A zero Table B does not.

Evidence: `README.md`, `SubmissionScoreRollup`, migration `2026-09-09-090000_AddUnlocksFullTotalToEvalFormPart.php`.

Implementation: Implemented. Confidence: High.

### FR-006 — Allow a Lead Auditor to submit an incomplete IPW audit

Unanswered items score 0. Pass and fail rules still run. Members and non-IPW-audit types do not get this exception.

Evidence: `staff_incomplete_ipw_audit_submit_allowed`.

Implementation: Implemented. Confidence: High.

### FR-007 — Probation follows the legacy lifecycle

Create, reuse, pass-close, staff edit limits, and the daily process behave as W7.

Evidence: `ProbationLifecycle`, `ProcessProbation`, `knowledgeBase/ProbationManagement.md`.

Implementation: Implemented in code. Production scheduling: Unknown.

### FR-008 — SEV audits do not write the scored compliance outcome

Application type 2 is excluded from pass-compliance writes.

Evidence: `PassComplianceWriter`.

Implementation: Implemented. Confidence: High for that exclusion. The relationship to status 40 is Requires Confirmation.

### FR-009 — Certificate PDFs use legacy wording and SAWIS farm names

Staff and scoped members can list and download certificates. Farm name resolution uses SAWIS farm master data.

Evidence: `Certificates`, `README.md`, `SawisFarmModel`.

Implementation: Implemented. Confidence: Medium (PDF layout was not rendered in this review; the README and controller exist).

### FR-010 — Documents download by UUID from the year/month audit folder

See W9.

Evidence: `README.md`, `DocumentManagement`.

Implementation: Implemented. Confidence: High for the route and storage note.

### FR-011 — Public pages stay available without login

See W1. Search engines receive a no-index filter on responses (`NoSearchEngineIndex`).

Evidence: `PublicRoutes.php`, `Filters.php`.

Implementation: Implemented. Confidence: High.

### FR-012 — English and Afrikaans

Form and UI strings exist for both languages. Evaluation form titles strip a stored “IPW EVALUATION FORM:” prefix at display time and show FARM / CELLAR without rewriting `form_desc`.

Evidence: `language_helper.php`, `README.md`, commit `1dc0392`.

Implementation: Implemented. Confidence: High for the display rule. Whether every content page has both languages is Unknown.

### FR-013 — Registration requires staff acceptance

The pending-registration endpoints exist. The exact fields required on `Welcome::register` were not enumerated in this PRD.

Evidence: `Routes.php` `register`, `acceptPendingMember`, `declinePendingMember`.

Implementation: Implemented endpoints. Field-level validation: not fully extracted. Confidence: Medium.

## Integrations

| System | Direction | What is exchanged | Auth as shown in code | Provenance |
|---|---|---|---|---|
| SAWIS BIS | Read into IPW | Member profile, farm master, SO2 rows. Legacy readme says no INSERT/UPDATE/DELETE against BIS | ODBC DSN and/or `X-API-Key` to the standalone API. `BisService` currently sets `$this->useApi = true` unconditionally | Implemented. Secret values are not copied here |
| SMTP / SendGrid | Outbound mail | `sendMail()` wraps CodeIgniter email. `env` lists `SMTP_*` and `SENDGRID_API_KEY` | Environment settings | Implemented mail helper. Which transport production uses is Unknown |
| OpenAI completions | Outbound | `chatGPTMessage()` posts to `https://api.openai.com/v1/completions` | A bearer token is hardcoded in `app/Helpers/chatgpt_helper.php` | Implemented helper. Product purpose of the ChatGPT screen is Unknown. The token must be rotated; it is not repeated in this PRD |
| Slack | Outbound | `slack_helper.php` and `Slack` controller are loaded from the base controller | Unknown | Helper exists |
| Salesforce | None active | `salesforceConnect()` body is commented out | n/a | Not an active integration |
| Africa’s Talking SMS | None active | `smsMessage()` body is commented out, including a sandbox key | n/a | Not an active integration. The commented key should be treated as exposed and is not repeated here |
| AWS | Library present | `aws/aws-sdk-php` is a Composer requirement | Unknown | Dependency only until a call site is confirmed |

Email pixel route: `GET tracking-pixel` (`EmailPixelTracking`). Provenance: **Implemented** route.

## Constraints

- PHP runtime used in CI is 8.3. Composer still allows `^8.0`. **Requires Confirmation** for production’s actual PHP version.
- Database driver in the `env` template is MySQLi, database name `adminoverdrivev2` on localhost. That template is a local starter default, not proof of the production schema name.
- CSRF and honeypot filters are present as aliases and commented out of the global before-list. **Implemented** as disabled.
- CI4 auto-routing is on (`setAutoRoute(true)`).
- Uploaded documents live under the public web root. Access control depends on the download controller, not on the files sitting outside `public/`.
- BIS and OpenAI helpers contain default or hardcoded credentials. Operating the system safely requires those values to live only in environment configuration and to be rotated. See technical documentation.
- The legacy knowledge base must not be used as the route map.

## Error and edge behaviour found in code

| Situation | Behaviour | Provenance |
|---|---|---|
| Member has no selected group | Submission and certificate queries return nothing | Implemented |
| Unknown status code | Badge and description say Unknown. Print labels also say Unknown, and they omit 30, 40, 61, and 90 | Implemented inconsistency |
| Missing submission id | JSON 400 “Submission ID is required” | Implemented |
| Submission not visible to the caller | JSON failure from `getAuthorizedSubmission` | Implemented |
| SEV score update cannot finish the audit log | Previously rolled back with the message in `AGENT_PLAN.md`. The plan says this was fixed | Documented fix. Re-test on current `054883d9` was not executed here |
| Probation command on a new environment | Knowledge base says dry-run first and do not rewrite imported rows | Documented |
| Upload 500 | README tells operators to check the audit directory, `fileinfo`, and PHP size limits | Documented |
| Legacy `.php` URL | Friendly moved/not-found page, including for anonymous users | Implemented |

## Out of scope for readers of this PRD

This document does not authorise new product behaviour. Conflicts in the confirmation list need a human decision before anyone changes scoring, SEV, or probation scheduling.
