Architecture العربية

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#

  1. Quarantine-first. Files land in an isolated bucket and are promoted only after scanning. An admin reviewer must never be the malware-detection mechanism.
  2. Magic bytes, not extensions. A .pdf extension and a client-declared application/pdf are attacker-controlled. Only content inspection is trustworthy.
  3. 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.
  4. Short TTL. ≤ 5 min bounds leaked-URL exposure (QAS-SEC-02).
  5. Immutable versions. Resubmission appends. An audit trail referencing a mutated document proves nothing.
  6. 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.


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-MOM REQ-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:

  1. 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.
  2. 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
Tech Lead · Amir Haroun Draft v0.1 · research & design only