Design & Engineering Decisions
This document records what we decided during our Component 1 domain-modelling and forensic audit sessions, why each decision was made after auditing the legacy CodeIgniter 3 / MariaDB application, and how it guarantees seamless continuity for Highclere Castle visitors and gate staff.
Authentication Strategy & Gate Scanner Protection
Passwordless Email OTP & Magic Link (Only)
What we decided: Customers sign into /login/ exclusively using a 6-digit Email OTP
or Magic Link sent via the Postmark REST API (15-minute expiry, SHA-256 hashed in PostgreSQL auth_otp_tokens,
rate-limited per IP and email).
The legacy users table stored passwords in plaintext and allowed multiple accounts with the same email
address. Migrating plaintext passwords is an unacceptable security risk, and forcing password resets across duplicate
email rows caused widespread login failures in the legacy site.
Keep On-Site Gate Lookups on the Existing Staff Gate List Stack
What we decided: We will not build a public unauthenticated "Order Number + Postcode" lookup form on the customer portal in Component 1. On-site gate lookups remain handled by Castle staff on their dedicated Gate List scanner/report stack.
Castle gate staff already operate from a daily downloaded Gate List report on a separate stack when assisting visitors at the gates. Avoiding a second public unauthenticated lookup surface keeps Component 1 simple (KISS) and eliminates order-enumeration attack vectors.
Unbreakable Gate Scanner Barcode Contract
Decision 2.1: Consolidate Customers by Normalised Email, Preserve legacy_user_id per Order
Architectural ADR What we decided:
- All legacy
usersrows sharing the sameLOWER(TRIM(email))are consolidated into a single PostgreSQLcustomersrecord. - When duplicate
usersrows have differing contact/address details, the most recentusersrow (highestid) wins for canonical profile fields, while all merged IDs are preserved incustomers.legacy_user_ids. - If an historical
orders.user_idis orphaned (missing fromusers), the ETL sync falls back toorder_user_info.email(the purchase-time billing snapshot) so no historical order is ever lost. - Every
ordersrow in PostgreSQL explicitly preserves its original MariaDBorders.user_idinorders.legacy_user_id.
On-site Castle gate scanners verify entry against the Gate List using a Code 128B barcode formatted as:
{8-digit zero-padded order_number}-{legacy_order_item_id}-{legacy_user_id}
Preserving orders.legacy_user_id per order guarantees that every barcode rendered by the new portal matches the staff Gate List bit-for-bit, while still giving the customer a single unified view of their entire order history.
Individual Credit Grants vs. Single Indistinct Pot
Decision 3.1: Model Each Gift Voucher as an Individual Grant (Oldest-Expiry-First)
Domain Sharpening What we decided:
In our domain model (CONTEXT.md), a Gift Voucher is an individual pre-paid monetary credit grant
attached to a Customer with its own initial_amount (including any percentage uplift), net remaining
balance_amount (after deducting redemptions in user_voucher_usage), and expires_at date.
The Customer Portal displays both the total combined available credit and the
individual breakdown of active Gift Vouchers with their exact expiry dates.
Since February 2021, customers have been able to hold multiple active vouchers simultaneously with different expiry dates.
The legacy Customers::orders() controller summed the balances but had an unresolved bug regarding which expiry
date to show. Showing each individual voucher and its net remaining balance removes all ambiguity for both customers and support staff.
SKU Filtering & Presentation at the Gate
Complete Non-Ticket SKU Filtering at the ETL Boundary
The ETL normaliser (src/etl/) computes is_scannable_ticket using the complete union of legacy rules:
- Order is confirmed (
status = 'confirmed') and item is not voided. - Item has a valid
visit_dateand non-emptyvisit_time. legacy_product_id < 500000(excludes champagne/hamper add-ons).- Excludes promotional book/gift SKUs (
174005,177025,178002–178027,181165–181170). - Picnic Afternoon Tea (Venue 16, SKUs 177014, 177024): Remains
is_scannable_ticket = truewith collection instructions.
One Ticket Stub per Item + Family Breakdown + Pre-Resolved Times
What we decided:
- Each eligible
order_itemsrow renders its own ticket stub with its own Code 128B barcode. - Schema preserves
family_adult_qtyandfamily_child_qtyso Family Tickets display "Family Ticket (2 Adults, 2 Children)". - Rescheduled tickets display staff notes from
date_changed_note. - During ETL,
event_launch,event_venues, and ticket text templates are pre-resolved ontoorder_items—eliminating 1,600 lines of hardcoded PHP conditionals.
Quarantining Voided Tickets & Reconciling Taxes
Hide Unpaid 'Pending' Orders & Quarantine Voided Barcodes
What we decided:
Legacy order_status = 'P' orders (abandoned at SagePay) are synced to PostgreSQL as 'pending' for
audit completeness but hidden from the Customer Portal.
On /account/orders/:orderNumber/, voided items show a clear "Voided — No Longer Valid" badge.
On /tickets/ and /invoice/, voided items are strictly excluded so a voided barcode
can never be printed or scanned at the gate.
Reconciled HTML/Print VAT Invoices (First-Class Domain Term)
What we decided:
Added VAT Invoice to CONTEXT.md. The ETL normaliser computes each item's effective
vat_rate (20.00% for UK admission/shop; 0.00% for books/exports).
Unlike legacy Pdf_vatinvoice.php (which omitted order discounts and produced mismatched totals), our
/invoice/ view includes discount_amount so Total including VAT always reconciles
with orders.total_amount, bearing Highclere Enterprises LLP's tax registration (GB 724 6472 28).
60-Second Incremental ETL & Deep-Link Routing
Bounded-Window Upsert Catches Staff Edits Without mdate
What we decided: Auditing legacy Orders_model.php revealed that staff actions in /admin
(such as ticket voiding or date swaps) do not update mdate.
Our 60-second incremental ETL sync (guarded by pg_try_advisory_lock) does not rely solely on mdate.
Instead, it syncs:
- Recent orders (
order_id >= max_synced_order_id - 5000ormdate >= now - 7 days), - All active/upcoming tickets (
VisitDate >= CURRENT_DATE - INTERVAL 7 DAYordate_changed IS NOT NULL), and - All
user_vouchers+user_voucher_usage.
Preserve ?next= Across OTP Login & Catch Legacy Links
What we decided:
- Unauthenticated requests to ticket views redirect to
/login/?next=...and land the customer directly on their tickets immediately after OTP verification. - Legacy
/customers/lostpasswordand/customers/passwordreset/*redirect to/login/?reason=passwordlesswith a clear notice explaining passwords are no longer needed. - Legacy deep links from old confirmation emails (
/customers/printorder/*) route straight to the new ticket view.