arc42 §6 — Runtime View: Data Flows & Security Flows
Status: Draft v0.1 · Date: 2026-07-20
Traces to: 00-requirements-baseline.md, 01-quality-attribute-scenarios.md, arc42/03-context-and-containers.md
This document covers the six runtime scenarios that carry the system's architectural risk. Routine CRUD flows are omitted deliberately — they exercise no interesting property.
| # | Scenario | Why it is architecturally significant |
|---|---|---|
| 1 | Authentication & token lifecycle | Trust boundary entry point; QAS-SEC-04 |
| 2 | Document upload & approval pipeline | Untrusted input; QAS-SEC-02, QAS-SEC-03, QAS-CMP-02 |
| 3 | Booking → payment → consultation | The CONF-02 money-before-agreement defect; QAS-AVL-01, QAS-AVL-03 |
| 4 | Assessment → grading → certification | Integrity chain; QAS-INT-01, QAS-INT-02, QAS-INT-03 |
| 5 | Marketplace search & featured ranking | QAS-PERF-02 Arabic search; commercial fairness |
| 6 | Tenant-scoped data access | QAS-SEC-01 — the highest-priority scenario in the system |
Flow 1 — Authentication & Token Lifecycle#
sequenceDiagram
autonumber
participant M as 📱 Mobile Client
participant ING as Ingress + WAF
participant API as Qwizin API
participant G as Google Identity
participant SMS as SMS Provider
participant R as Redis
participant DB as PostgreSQL
rect rgb(240,240,255)
Note over M,DB: Registration — phone path
M->>API: POST /v1/auth/register {phone, name, type}
API->>API: Validate, rate-limit by IP + phone
API->>R: Store OTP hash, TTL 5min, attempt counter
API->>SMS: Send OTP
Note over API,SMS: ⚠ Toll-fraud control: per-phone,<br/>per-IP, per-country quotas
M->>API: POST /v1/auth/verify {phone, otp}
API->>R: Verify hash + increment attempts
Note over API,R: Lock after N attempts.<br/>Constant-time compare.
API->>DB: Create user (state: pending_profile)
API-->>M: {access_token 15m, refresh_token}
end
rect rgb(240,255,240)
Note over M,DB: Registration — Google path
M->>G: Native Sign-In → ID token
M->>API: POST /v1/auth/google {id_token}
API->>G: Fetch JWKS (cached)
API->>API: ⚠ Verify signature, iss, aud, exp, nonce
Note over API: NEVER trust client-supplied<br/>profile claims. Server verifies<br/>the token itself.
API->>DB: Find or create by verified sub
API-->>M: {access_token, refresh_token}
end
rect rgb(255,245,235)
Note over M,DB: Refresh with reuse detection — QAS-SEC-04
M->>API: POST /v1/auth/refresh {refresh_token}
API->>DB: Look up token → family_id, used_at
alt Token already consumed (replay / theft)
API->>DB: 🚨 Revoke ENTIRE token family
API->>API: Emit security event, notify user
API-->>M: 401 — re-authentication required
else Token valid
API->>DB: Mark consumed, issue successor in same family
API-->>M: {new access_token, new refresh_token}
end
end
Controls#
| Control | Rationale |
|---|---|
| Access token TTL ≤ 15 min; refresh long-lived but rotating | Limits stolen-token window without forcing constant re-login on mobile |
| Reuse detection revokes the whole family | A consumed refresh token being presented again means either theft or a client bug. Both warrant killing the family — this is the single highest-value auth control. |
| Server-side Google ID token verification | The common vulnerability is trusting a client-supplied email/sub. Verify signature and audience server-side, always. |
| OTP: hashed at rest, constant-time compare, attempt lockout, quota by phone/IP/country | OTP endpoints are a standard toll-fraud target — an attacker burns the SMS budget without ever creating an account |
| Password change / admin suspend → revoke all families | Required for QAS-SEC-04 |
| Admin accounts: MFA mandatory | Admin can approve accounts and see all tenants — highest-value target in the system |
Note on the framework gap: Laravel Sanctum provides token expiration but no native refresh-token rotation. The rotation-with-reuse-detection scheme above must be implemented explicitly. This is a known, verified gap and should not be assumed to come for free.
Flow 2 — Document Upload & Approval Pipeline#
This is the system's primary untrusted-input path. ACT-CO / ACT-SP / ACT-CON upload commercial registrations and tax cards; ACT-ADM reviews them.
sequenceDiagram
autonumber
participant U as 🏢 Company Admin
participant API as Qwizin API
participant OBJ as Object Storage
participant Q as Queue
participant SC as Malware Scanner
participant ADM as 🛡️ Platform Admin
participant DB as PostgreSQL
rect rgb(255,240,240)
Note over U,DB: 🔒 TRUST BOUNDARY — untrusted file enters
U->>API: POST /v1/onboarding/documents (multipart)
API->>API: AuthZ: is this user's own org?
API->>API: Validate size, count, declared type
API->>API: ⚠ Magic-byte inspection<br/>(NOT extension or client MIME)
API->>OBJ: PUT → quarantine/ bucket
Note over OBJ: Quarantine is a SEPARATE bucket.<br/>No read access from admin UI.<br/>Encrypted at rest.
API->>DB: document(state: uploaded, version: n)
API->>Q: dispatch ScanDocument
API-->>U: 202 Accepted — "under review"
Note over API,U: Async by design: user is not<br/>blocked on the scan (QAS-SEC-03)
end
Q->>SC: Scan object
alt Malware detected
SC-->>DB: state: rejected_malware
Note over DB: Admin NEVER sees the file.<br/>Security event raised.
DB->>U: Notify — resubmit required
else Clean
SC->>OBJ: Move quarantine/ → documents/
SC->>DB: state: pending_review
DB->>ADM: Enqueue in review queue
end
rect rgb(240,240,255)
Note over ADM,DB: Admin review — QAS-SEC-02
ADM->>API: GET /v1/admin/documents/{id}
API->>API: AuthZ: admin role + review permission
API->>OBJ: Generate signed URL, TTL ≤ 5 min
API->>DB: 📝 Audit: admin X viewed doc Y at T
API-->>ADM: {signed_url}
ADM->>OBJ: GET via signed URL
Note over ADM,OBJ: Direct fetch. Never proxied<br/>through app domain.<br/>Content-Disposition: attachment
end
rect rgb(240,255,240)
Note over ADM,DB: Decision — QAS-CMP-02
alt Approved
ADM->>API: POST /v1/admin/documents/{id}/approve
API->>DB: BEGIN TX
API->>DB: document.state = approved
API->>DB: 📝 Audit: actor, timestamp, prior→new, reason
API->>DB: Re-evaluate org activation state
API->>DB: COMMIT
Note over DB: Org activates ONLY when ALL<br/>required docs approved (CON-04)
DB->>U: Notify — account active
else Rejected
ADM->>API: POST .../reject {reason}
API->>DB: state = rejected + reason + audit
DB->>U: Notify with reason → resubmission allowed
Note over DB: Resubmission creates version n+1.<br/>⚠ NEVER overwrites version n —<br/>the evidence behind the original<br/>decision must survive (QAS-CMP-02)
end
end
Pipeline invariants#
- Quarantine-first. Files land in an isolated bucket and are promoted only after scanning. An admin reviewer must never be the malware-detection mechanism.
- Magic bytes, not extensions. A
.pdfextension and a client-declaredapplication/pdfare attacker-controlled. Only content inspection is trustworthy. - Never served from the app origin. Signed URLs from a storage domain,
Content-Disposition: attachment. Serving user uploads from the application domain turns a stored file into stored XSS against the admin session — the highest-value session in the system. - Short TTL. ≤ 5 min bounds leaked-URL exposure (
QAS-SEC-02). - Immutable versions. Resubmission appends. An audit trail referencing a mutated document proves nothing.
- Asynchronous acceptance. Scanning must not block the user (
QAS-SEC-03).
Flow 3 — Booking → Payment → Consultation#
⚠️ This flow implements the defect identified as CONF-02. SRC-BRD §10.4 orders it: book → pay → consultant approves. Money is captured before the consultant agrees. The design below contains that defect rather than propagating it.
sequenceDiagram
autonumber
participant C as 👤 Customer
participant API as Qwizin API
participant PAY as 💳 Payment Gateway
participant CON as 🎓 Consultant
participant VID as 🎥 Video Provider
participant SCH as Scheduler
participant Q as Queue
rect rgb(240,240,255)
Note over C,PAY: Booking + payment AUTHORIZATION (not capture)
C->>API: POST /v1/bookings {consultant, slot, type}
API->>API: Validate slot free, check consultant active
API->>API: 🔒 Pessimistic lock on slot
API->>API: booking(state: pending_payment), slot held
API-->>C: {booking_id, amount, idempotency_key}
C->>API: POST /v1/bookings/{id}/pay {idempotency_key}
API->>PAY: AUTHORIZE (hold) — not capture
Note over API,PAY: ⚠ AUTHORIZE ONLY.<br/>Capture happens after the<br/>consultant accepts. This is the<br/>fix for CONF-02.
PAY-->>API: {auth_id, status}
API->>API: booking(state: awaiting_consultant)
API->>CON: 🔔 Booking request — respond within SLA
end
rect rgb(255,245,235)
Note over CON,PAY: Consultant decision — bounded by timer
alt Consultant accepts within SLA
CON->>API: POST /v1/bookings/{id}/accept
API->>PAY: CAPTURE {auth_id}
PAY-->>API: captured
API->>API: booking(state: confirmed)
API->>Q: Schedule reminders (T-24h, T-1h)
API->>C: 🔔 Confirmed
else Consultant rejects
CON->>API: POST /v1/bookings/{id}/reject
API->>PAY: VOID {auth_id}
Note over API,PAY: Void, not refund — no funds<br/>ever moved. Cleaner for the<br/>customer and cheaper in fees.
API->>API: booking(state: rejected), release slot
API->>C: 🔔 Declined — no charge
else SLA expires (no response)
SCH->>API: Expire booking
API->>PAY: VOID {auth_id}
API->>API: booking(state: expired), release slot
API->>C: 🔔 Expired — no charge
Note over API: ⚠ Slot MUST be released.<br/>Consultant non-response must not<br/>silently consume inventory.
end
end
rect rgb(240,255,240)
Note over C,VID: Consultation — QAS-AVL-01
SCH->>API: T-5min: provision session
API->>VID: createRoom(booking_id) [via port]
VID-->>API: {room_ref}
C->>API: POST /v1/bookings/{id}/join
API->>API: AuthZ: participant + within window
API->>VID: issueToken(room, identity, role)
API-->>C: {join_token, room_ref}
C-->>VID: 🎥 WebRTC media — DIRECT, bypasses API
Note over C,VID: Media never traverses the API tier.<br/>API controls session lifecycle only.
VID->>API: webhook: participant_joined / left / ended
API->>API: Record actual duration + outcome
end
rect rgb(255,240,240)
Note over C,PAY: Failure path
alt Session fails to establish
API->>API: Detect: no participants joined in window
API->>API: Flag for resolution
API->>PAY: REFUND (policy-driven)
Note over API,PAY: ⚠ Policy undefined in sources.<br/>Who bears a failed session?<br/>→ OQ-05
end
end
The CONF-02 resolution#
Problem in SRC-BRD §10.4 |
Design response |
|---|---|
| Customer charged before consultant agrees | Authorize (hold), capture only on acceptance. Funds are never taken for a service not agreed. |
| No defined behaviour on consultant rejection | Void the authorization. No refund needed — no money moved. |
| No behaviour on consultant non-response | Booking-expiry timer + automatic void + slot release. Without this, an unresponsive consultant silently consumes their own inventory and holds the customer's funds. |
| No dispute path for a failed session | Flagged as OQ-05 — a business policy decision, not an architectural one. The architecture provides the mechanism; the sponsor must set the policy. |
Gateway requirement generated by this flow: the payment provider must support authorization/capture separation and voids. Not all KSA gateways do, particularly for mada. This is now a hard selection criterion feeding
OQ-01.
Idempotency#
Every payment-mutating call carries a client-supplied idempotency key (QAS-AVL-03). Webhooks from the gateway are signature-verified and processed idempotently — gateways retry, and duplicate processing of a capture is a double charge.
Flow 4 — Assessment → Grading → Certification#
The integrity chain. Certificates assert food-safety competence, so every link must be tamper-resistant.
sequenceDiagram
autonumber
participant E as 👷 Employee
participant API as Qwizin API
participant DB as PostgreSQL
participant Q as Queue
participant PDF as Document Renderer
participant OBJ as Object Storage
participant V as 🔍 Public Verifier
rect rgb(240,240,255)
Note over E,DB: Delivery — QAS-INT-01
E->>API: POST /v1/assessments/{id}/start
API->>API: AuthZ: assigned + attempts remaining
API->>DB: attempt(state: in_progress, started_at)
API->>DB: Fetch questions
API->>API: ⚠ STRIP all correctness indicators
API-->>E: {questions, options} — NO answer key
Note over API,E: 🚨 The correct answer NEVER<br/>reaches the client in any form:<br/>not a flag, not ordering, not<br/>a hash, not in metadata.<br/>Contract-tested in CI.
end
rect rgb(240,255,240)
Note over E,DB: Submission & server-side grading — QAS-INT-02
E->>API: POST /v1/assessments/{id}/submit {answers}
API->>API: Validate attempt open + not expired
API->>DB: BEGIN TX
API->>DB: Load answer key (server-side only)
API->>API: 🧮 Grade — score computed here, never received
API->>DB: Load passing_score (admin-configurable, CON-08)
API->>DB: attempt(answers, score, passed) — APPEND-ONLY
Note over DB: Every attempt persisted immutably.<br/>Client-submitted scores are<br/>never trusted or even accepted.
alt Passed
API->>DB: certificate(state: pending, attempt_id) 🔗
Note over DB: Certificate transactionally bound<br/>to a specific graded attempt
API->>Q: dispatch GenerateCertificate
end
API->>DB: COMMIT
API-->>E: {score, passed} — result never lost even if<br/>PDF generation later fails (QAS-AVL-04)
end
rect rgb(255,245,235)
Note over Q,OBJ: Async generation — QAS-SCL-03, QAS-LOC-01
Q->>PDF: Render {holder, competency, date, cert_id, QR}
Note over PDF: ⚠ Arabic: contextual glyph joining,<br/>bidi layout, embedded fonts.<br/>Golden-image tested.
PDF->>PDF: Embed QR → verification URL
PDF->>PDF: Apply cryptographic signature
PDF->>OBJ: Store immutably
PDF->>DB: certificate(state: issued, hash)
DB->>E: 🔔 Certificate ready
end
rect rgb(240,240,255)
Note over V,DB: Third-party verification — QAS-INT-03
V->>API: GET /verify/{cert_id} (public, unauthenticated)
API->>DB: Look up by unguessable ID
API->>API: Evaluate CURRENT validity:<br/>revoked? company suspended? expired?
API-->>V: {holder, competency, issued, status}
Note over API,V: Minimal disclosure — enough to<br/>verify, not enough to enumerate<br/>or harvest personal data.<br/>Rate-limited.
end
Integrity controls#
| Threat | Control |
|---|---|
| Answer key harvested from API response | Correctness indicators stripped server-side; CI contract test asserts their absence from the delivery schema |
| Client submits a forged score | Score is computed server-side from answers; a client-supplied score field does not exist in the API |
| Retry-until-pass, presenting only the pass | All attempts append-only and retained; attempt limits configurable |
| Certificate forged in a graphics editor | QR → public verification endpoint; cryptographic signature; unguessable IDs |
| Certificate remains "valid" after revocation | Verification returns current status computed live, not a snapshot baked into the PDF |
| Enumeration of certificates to harvest names | Non-sequential unguessable IDs; rate limiting; minimal response payload |
Open dependency:
OQ-07— when a company is suspended, do its employees' issued certificates remain valid? The verification endpoint returns current status, so it must have an answer. This is a business-policy question with a directly visible technical consequence, which is why it needs a sponsor ruling.
Flow 5 — Marketplace Search & Featured Ranking#
sequenceDiagram
autonumber
participant U as 👤 User
participant API as Qwizin API
participant SRCH as Search Engine
participant DB as PostgreSQL
participant Q as Queue
rect rgb(240,255,240)
Note over DB,SRCH: Indexing pipeline — async
DB->>Q: Provider created / updated / subscription changed
Q->>Q: ⚠ Debounce: subscription state changes<br/>are frequent and bursty
Q->>SRCH: Upsert document
Note over SRCH: Indexed: name, description,<br/>categories, city, ar+en variants,<br/>subscription_tier, is_featured
end
rect rgb(240,240,255)
Note over U,SRCH: Query — QAS-PERF-02
U->>API: GET /v1/marketplace?q=مورد أغذية&city=..&cat=..
API->>API: Normalise Arabic:<br/>أإآ→ا · ة→ه · ى→ي · strip diacritics · strip tatweel
API->>SRCH: Query with filters + featured boost
SRCH->>SRCH: Relevance score
SRCH->>SRCH: Apply featured multiplier
Note over SRCH: ⚠ Boost is a MULTIPLIER on<br/>relevance, not an override.<br/>An irrelevant featured provider<br/>must NOT outrank a relevant one.
SRCH-->>API: Ranked IDs
API->>DB: Hydrate current data by ID
Note over API,DB: Index is for ranking only.<br/>Authoritative data from DB —<br/>a stale index must never show<br/>a suspended provider as active.
API->>API: Filter: active + approved + subscription current
API-->>U: Results + featured badge
end
rect rgb(255,240,240)
alt Search engine unavailable — QAS-AVL-04
API->>DB: Fallback: DB-backed filtered listing
API-->>U: Degraded results, not an error page
end
end
Design notes#
- Arabic normalisation is mandatory, not optional. Saudi users type أ/ا/إ interchangeably and rarely use diacritics. Without folding, recall collapses and the directory appears empty — the failure is silent and total. This constrains engine choice and is a
(H,H)scenario. - Bilingual indexing. Arabic and English variants indexed as separate analysed fields so a query in either language matches (
OQ-12). - Featured boost is bounded.
SRC-MOMREQ-06 requires featured providers to rank above others. Implemented as a relevance multiplier: it reorders comparable results, it does not surface irrelevant ones. An unbounded boost destroys search utility and, ultimately, the value of the featured product itself. - Index is never authoritative. Post-filtering against the database prevents a stale index from exposing a suspended or lapsed provider — a commercial and compliance risk, not just a correctness one.
- Debounced indexing. Subscription state changes are bursty (renewals, expiries); indexing them synchronously would thrash the engine.
Flow 6 — Tenant-Scoped Data Access ⭐#
QAS-SEC-01 is the highest-priority scenario in the system. This flow describes the mechanism, because the mechanism is what is testable.
flowchart TB
REQ["Incoming authenticated request"] --> AUTH["Authenticate<br/>→ resolve user + org context"]
AUTH --> CTX["Bind tenant context<br/>to the request scope"]
CTX --> POL["Policy / Gate check<br/><i>coarse-grained capability</i>"]
POL --> QRY["Repository / query executed"]
QRY --> SCOPE{"Global tenant scope<br/>auto-applied?"}
SCOPE -->|"Yes — default"| SAFE["✅ Query constrained<br/>to tenant"]
SCOPE -->|"Explicitly bypassed"| GUARD{"Bypass on the<br/>allow-list?<br/><i>admin / system context</i>"}
GUARD -->|Yes| AUDIT["✅ Permitted<br/>📝 audit-logged"]
GUARD -->|No| FAIL["🚨 FAIL CLOSED<br/>Exception + alert"]
SAFE --> RESP["Response"]
AUDIT --> RESP
FAIL --> ERR["500 + security event<br/><i>never silently unscoped</i>"]
style FAIL fill:#c94a4a,stroke:#8b2f2f,color:#fff
style ERR fill:#c94a4a,stroke:#8b2f2f,color:#fff
style SAFE fill:#2d8659,stroke:#1c5638,color:#fff
style AUDIT fill:#2d8659,stroke:#1c5638,color:#fff
Why enforcement sits at the persistence layer#
The tempting design is per-endpoint authorization: each controller checks $employee->company_id === $user->company_id. This fails predictably, for a structural reason — it is correct only if every developer remembers it on every endpoint, forever. The first forgotten check is a cross-tenant breach, and it will be forgotten, because nothing enforces it.
Instead:
Five layers, ordered by how late each catches a mistake. Layers 1 and 5 are the ones that survive staff turnover; 2–4 are ergonomics that make the right thing easy.
| # | Layer | Responsibility |
|---|---|---|
| 1 | PostgreSQL Row-Level Security ⭐ | The backstop. RLS policies on every tenant-scoped table filter on a session variable. This is the only layer that fails closed regardless of application bugs — a forgotten scope, a raw DB::table() query, or a withoutGlobalScopes() call returns zero rows instead of another company's data. |
| 2 | TenantContext that throws |
One injectable object holding the current company. Reading it when unset raises — it never returns null and never defaults to "no filter". Nullability here is how a WHERE company_id IS NULL matching nothing (or a dropped predicate) gets shipped. |
| 3 | Eloquent global scope | Convenience and query performance — RLS filters, but the scope lets the planner use the company_id index. Not a security boundary. Treating it as one is the classic mistake. |
| 4 | Architecture test | Asserts every model whose table has a company_id column uses the scoping trait. Catches the model someone adds next year — precisely when boundaries decay. |
| 5 | Adversarial CI suite ⭐ | Seeds two companies with identical-looking data, authenticates as A, enumerates every endpoint using B's IDs, asserts 403/404 and that no response body contains B's identifiers. New endpoint without coverage → build fails. |
⚠ Two RLS failure modes that must be designed for now#
Both are cases where the policy is written correctly and data still leaks:
- Connection reuse. With connection pooling in transaction mode — or persistent workers under Octane — a session variable set for one request can survive into the next, serving one tenant's data under another's context. Mitigation: set the variable with transaction-scoped semantics inside an explicit transaction, and reset it in terminating middleware. This is the actual leak vector, not the policy.
- Queued jobs have no request. A background job runs with no tenant context, so the session variable is unset. Every tenant-scoped job must re-establish context from an identifier carried in its payload — and a job that fails to set it must throw, not default.
These two are why RLS is necessary but not sufficient, and why layer 5 tests observable behaviour rather than internals.
Design principle: fail closed. A query reaching the database without tenant context is a bug that must surface loudly in development rather than leak quietly in production.
Compliance benefit#
Database-enforced isolation is materially more defensible to a regulator than application-level convention. "Failure to implement technical and organisational safeguards" is among the categories in SDAIA's reported enforcement activity — and "we have RLS policies at the database layer" documents far better in an audit than "we have a trait developers are supposed to use."
Cascade behaviour#
When a company is suspended or rejected, the change must propagate to employee access, in-flight assessments, and issued certificates. OQ-07 is unresolved — but the mechanism above ensures the propagation point is one place (the tenant context resolution), not scattered across every module.
Cross-Cutting Runtime Concerns#
Correlation and traceability (QAS-OPS-01)#
Every request is assigned a correlation ID at ingress that propagates through the API, into queued jobs, into sidecar calls, into external provider calls, and into every log line. Without this, debugging an async chain — upload → scan → review → approve → notify — means manually correlating timestamps across four components at 02:00.
Async boundaries#
| Synchronous (in request path) | Asynchronous (queued) |
|---|---|
| Authentication, authorization | Malware scanning |
| Booking slot reservation | Certificate/PDF generation |
| Payment authorization | Notifications (email/SMS/push) |
| Assessment grading | Search indexing |
| Search query | Payment reconciliation |
| Document metadata write | Webhook processing |
Rule: no non-critical dependency sits in the synchronous path of a critical flow (QAS-AVL-04). Grading is synchronous because the user is waiting for a result and it is cheap; certificate rendering is asynchronous because it is expensive and the result is already safely persisted.
Idempotency#
All externally-triggered mutations — client payment calls, gateway webhooks, video provider webhooks — are idempotent by key. External systems retry; non-idempotent handling means double charges and duplicate bookings.
Open Items Affecting Runtime Behaviour#
| ID | Question | Flow affected |
|---|---|---|
OQ-01 |
Gateway must support authorize/capture/void separation | Flow 3 — hard selection criterion |
OQ-03 |
Consultant availability model | Flow 3 — slot generation |
OQ-05 |
Refund policy for failed sessions and consultant non-response SLA | Flow 3 |
OQ-07 |
Company suspension cascade to certificates | Flows 4, 6 |
OQ-08 |
Consultation recording | Flow 3 — adds egress + retention path |
OQ-09 |
Certificate verifiability confirmation | Flow 4 |
OQ-12 |
Bilingual content scope | Flow 5 — index field design |