version: 1.2.0
updated: 2026-07-29
owner: iOS platform
description: >
  The rule registry. STANDARD.md teaches these to a newcomer with examples; EXAMPLES.md carries a
  worked ✗/✓ pair per judgement rule; SKILL.md audits against them. IDs are stable and never
  renumbered  -  a rule is retired by status, not deletion.

# What these rules may be applied to. A consumer resolves this BEFORE selecting rules,
# and records how many it dropped and why. Without it every rule below would be applied
# to every changed file, which on an Objective-C or UIKit diff manufactures findings and
# buries the real ones  -  worse than declaring no coverage at all.
#
# Per-rule `scope:` narrows this further and never widens it. A rule with no `scope:`
# inherits the block below.
scope:
  languages: [swift]
  paths:
    - "**/*.swift"
  excludePaths:
    - "**/*.generated.swift"
    - "**/Generated/**"
    - "**/*.pb.swift"
  # Named so a consumer can report the gap rather than silently covering nothing:
  # these languages appear in iOS repos and this registry does NOT speak for them.
  notCovered:
    objective-c: "*.m / *.mm / *.h  -  no ObjC rules exist here; report as a coverage gap, never apply Swift rules to them"
    uikit: "UIKit view controllers are Swift, so file-level rules apply, but the UI-* family assumes SwiftUI view construction and is scoped out below"
    c-cpp: "*.c / *.cc / *.hpp  -  out of scope"

severity_levels: [blocking, important, suggestion]
enforcement_kinds:
  format: the formatter owns it; not reviewed by humans
  lint: a linter decides it mechanically
  scan: a tool measures it (dead code, coverage, graph)
  judgement: requires a human or an audit run
exception_marker: "// standard:exception(<RULE-ID>) <reason> <expiry:YYYY-MM-DD>"

# ---------------------------------------------------------------------------
# Roles  -  what the structure rules name instead of paths
# ---------------------------------------------------------------------------
# A structure rule never names a folder or a type; it names a role, and the module's overlay binds
# that role to its own spelling. An UNBOUND role disables every rule that reads it, and the audit
# reports that rather than guessing a shape. Substitutions available in a binding: {dir}, {stem}.
roles:
  screen.root: the directory that is one screen; everything else resolves relative to it
  screen.entry: the file a coordinator or factory constructs to show the screen
  screen.viewmodel: the type holding the screen's behaviour and service calls
  screen.state: the type holding UI state, when the module separates it from the view model
  screen.analytics: the screen's analytics surface
  screen.factory: the seam another module calls to build the screen
  screen.mapper: the wire-to-domain translation for the screen
  service.dir: one directory per service operation, holding that operation's models
  service.request: the request half of a service operation
  service.response: the response half of a service operation
  subview.view: an extracted view belonging to one screen
  subview.configuration: the value type an extracted view renders
  repository.live: the production implementation of a screen's data access
  repository.mock: the scripted implementation previews and the debug menu use
  shared.root: the module's cross-screen folder
  source.root: the module's source tree, as the mirror rule's counterpart to the test tree
  test.root: the module's test tree

module_overlay_slots:
  description: >
    Some rules govern a CHOICE rather than a defect: two shapes are each internally coherent, the
    cost is only in mixing them, and picking one is the module's call. Writing one of them into the
    shared registry turns every module that chose the other into a hundred findings  -  which is a
    migration proposal wearing a standards pass. Those rules bind to a slot here instead. The
    module's `modules/<Module>.yml` overlay binds it; an UNBOUND slot disables its rules, and the
    audit says so in the plan rather than defaulting to one dialect silently.
    A slot is only legitimate when both values are genuinely defensible. `UI-04` is the
    counter-example: a module with no copy surface has not chosen a different dialect, it is
    missing the surface, so that rule is not slotted.
  slots:
    - id: ScreenLayerShape
      governs: [STRUCT-02, STRUCT-03]
      values:
        layered: every screen carries the same layer folders, including the ones empty for it.
        organic: a screen carries only the layers it actually has.
      note: Decides whether "this screen has no data layer" is a finding or a fact.
    - id: ServiceModelDir
      governs: [STRUCT-09]
      values:
        under-data: the request/response pair sits in the data layer, beside the calling code.
        under-mapper: the pair sits beside the mapper that translates it.
    - id: ServiceModelPairing
      governs: [STRUCT-09]
      values:
        both: every operation models a request and a response, even when the request is empty.
        response-only: a request is modelled only when the call carries a body.
    - id: ScreenAssemblyShape
      governs: [STRUCT-10]
      values:
        per-screen-factory: each screen ships its own construction seam.
        shared-factory: one factory per module builds every screen.
    - id: UIStateHolder
      governs: [STRUCT-11]
      values:
        separate-state-type: UI state lives in its own type beside the view model.
        view-model-owned: the view model holds UI state directly.
    - id: SubviewShape
      governs: [READ-04b, STRUCT-12, STRUCT-13]
      values:
        folder-per-subview: each extracted view gets a folder holding it and its value type.
        flat: extracted views sit loose in one folder, with no required companion.
    - id: ScreenCompositionShape
      governs: [STRUCT-07]
      values:
        extracted: a fragment of the screen becomes a named view beside it.
        in-file: the entry file composes its own fragments.
      note: >
        Both keep the body readable, which is what STRUCT-07 protects. Counting view members on
        entry files against extracted view files tells you which one the module chose.
    - id: ServiceNamingScheme
      governs: [SVC-07]
      values:
        send-path: >
          a method wrapping one service call is named after the endpoint path, prefixed to say a
          request leaves the device. One spelling on both sides of the wire; a grep from the
          endpoint reaches every layer.
        domain-verb: >
          the method is named after what the domain asks for. Reads more naturally at the call site
          and survives an endpoint being renamed, at the cost of the endpoint-to-code grep.
      note: >
        Module-wide, never per screen. Changing the bound value is a rename migration with its own
        ticket, not a review comment.
    - id: NavExitShape
      governs: [NAME-01]
      values:
        coordinator-event: a typed event enum per screen plus a handler alias the coordinator applies.
        output-closure: an output enum per screen delivered through a closure the factory injects.
      note: Both enumerate every exit in one place, which is the property NAME-01 actually protects.
    - id: ComponentsDir
      governs: [READ-04b]
      values:
        Presentation/Components: the conventional spelling.
        Presentation/Subviews: an equally clear alternative in use.
      note: The name is free; using two of them in one module is not.

persistence_decision:
  description: >
    Answer this BEFORE reaching for storage. Keychain is the answer to "where does a persisted
    secret live", not to "this value is sensitive". Most sensitive values in a flow never need to
    persist at all, and persisting them is the more expensive mistake.
  ladder:
    - step: 1
      question: Does this value need to outlive the current flow?
      default: "No  -  assume transient until a requirement says otherwise"
      if_no: >
        Keep it in memory for the duration of the flow and drop it when the flow ends. Do NOT
        write it to the Keychain: an unnecessary keychain item survives the flow, survives logout
        unless someone remembers to delete it, and creates a cleanup obligation nobody owns.
        Over-persisting is itself a finding, not a safe default.
      if_yes: go to step 2
    - step: 2
      question: What does it need to survive  -  app backgrounding, app restart, or reinstall?
      guidance: >
        Backgrounding only -> in-memory state owned by the flow's model is still correct.
        App restart -> Keychain with the accessibility class the data demands.
        Reinstall -> a deliberate product decision that someone signs off; never a storage default.
    - step: 3
      question: Which data class is it?
      guidance: >
        The class (below) decides the storage tier and the logging rule. never-persist-locally
        classes stay transient no matter what step 2 said  -  the answer to "it needs to survive"
        for a payment instrument or a biometric is a server-side or system-provided token, not
        local storage.
  transient_obligations: >
    Transient does not mean unregulated. A value held only in memory still obeys SEC-03 (never
    logged), SEC-05 (cleared when the flow ends or the session drops, hidden from the app-switcher
    snapshot), and SEC-06 (never sent to analytics).

sensitive_data_classes:
  description: >
    The SEC rules are written against these CLASSES, never against one module's field names.
    Each audited module resolves its own concrete instances into a sensitive-data inventory
    (SKILL.md Phase 2d), and the lint mechanisms are generated from that inventory rather than
    hardcoded. A class with no instance in the module simply yields no rule.
    The at_rest value below applies ONLY to values that passed the persistence ladder  -  it says
    where a value goes IF it must persist, never that it must.
  classes:
    - id: auth-token
      covers: session, access, refresh and bearer tokens; loyalty or membership auth tokens; API credentials
      at_rest: keychain-required
      loggable: never
    - id: credential
      covers: password, PIN, passcode, security answer, one-time code
      at_rest: keychain-required
      loggable: never
      note: prefer never persisting at all; a one-time code has no reason to outlive its use
    - id: government-id
      covers: national identity number, tax number, driving licence number
      at_rest: keychain-required
      loggable: never
    - id: travel-document
      covers: passport number and expiry, visa data, residence permit
      at_rest: keychain-required
      loggable: never
    - id: booking-reference
      covers: reservation code, ticket number, boarding pass payload, barcode data
      at_rest: keychain-or-protected
      loggable: never
      note: individually low-value, but it authorises access to a passenger record
    - id: membership-identity
      covers: loyalty or programme membership number, tier, account identifier
      at_rest: keychain-or-protected
      loggable: hashed-or-truncated-only
    - id: payment-instrument
      covers: card number, expiry, CVV, tokenised card reference, billing address
      at_rest: never-persist-locally
      loggable: never
    - id: personal-contact
      covers: full name, date of birth, email, phone, postal address
      at_rest: protected-storage
      loggable: never
      note: name alone is low risk; name paired with date of birth or a document number is not
    - id: biometric-or-health
      covers: any biometric template, health or accessibility need, special-category data
      at_rest: never-persist-locally
      loggable: never
    - id: precise-location
      covers: device coordinates beyond city granularity
      at_rest: never-persist-locally
      loggable: never

rules:
  # ── READ  -  format & readability ───────────────────────────────────────────
  - id: READ-01
    title: MARK sections separate concerns  -  business rules, service calls, UI, one group each
    severity: important
    predicate: vocabulary
    params:
      vocabulary_key: SectionHeadings
      declaration: '^\s*//\s*MARK:\s*-\s*(.+)$'
      exempt_patterns: ['^[a-z][a-zA-Z0-9/{}.\- ]*$', '→', '↔']
    enforcement: lint
    rationale: readability
    check: >
      A file mixing a service call, a business rule and a view fragment under one MARK (or none)
      is a finding even when short. Section order follows the module's conventions doc.
      In a use case or repository implementation the sections are named after WHAT EACH TALKS TO
       -  `Service requests`, `On-device cache`, and so on  -  so a reader sees at a glance which
      calls leave the device and which do not.

  - id: READ-02
    title: Comments are clear statements; no AI or tool attribution anywhere
    severity: blocking
    enforcement: lint
    mechanism: "custom regex: (?i)(generated by|copilot|co-authored-by:.*(claude|anthropic|ai))"
    rationale: readability
    check: >
      Trim a comment by deleting what the code already says, the design-frame archaeology and the
      historical aside  -  never the decision. If a sentence records WHY a shape was chosen, or what
      breaks without it, it stays however long the block ends up.

      Carve-out  -  a comment that IS data is not prose and is not trimmed: a trigger/prefix table,
      a state-transition matrix, a wire-contract enumeration. Those exist nowhere else in the
      codebase, so shortening them destroys the only copy. Judge a long block by whether it
      repeats the code or replaces a missing document.

  - id: READ-03
    title: Size thresholds with carve-outs  -  screen 600 / file 400 / function 40 lines
    severity: important
    predicate: file_size
    params:
      test_marker: /Tests/
    enforcement: lint
    mechanism: swiftlint file_length, function_body_length, type_body_length
    rationale: readability
    exempt: [generated sources, mock/fixture data files]
    tooling_limitation: >
      SwiftLint's built-in length rules take no per-rule `excluded`, so the mock/fixture carve-out
      cannot be expressed in the config  -  those files still report. Honour the exemption in the
      audit pass and in the baseline; do not "fix" a fixture file to satisfy a line count.
    check: File over 120 lines with zero MARK is a separate finding  -  a reader has no map.

  - id: READ-04
    title: Component placement is decided by call-site count, not by feel
    severity: important
    enforcement: judgement
    rationale: readability
    check: >
      2+ call sites -> own file with its own configuration in the shared layer. Exactly one call
      site and bound to the screen's state -> private view builder in a MARK'd extension.
      Both directions are findings.

  - id: READ-04e
    title: Header chrome belongs to the header, not to the scroll content
    severity: suggestion
    enforcement: judgement
    rationale: readability
    check: >
      A bar that reads as part of the screen's header  -  a route chip, a countdown, a step bar -
      goes inside the header component's add-on slot, not as the first row of the scroll
      content. Rendered below the header it squares off the header's rounded bottom corners and
      scrolls away from the title it belongs to. The header component has the slot; use it.

  - id: READ-04b
    title: A view fragment that renders a thing is a component file with a preview
    severity: important
    predicate: sibling_required
    params:
      slot: SubviewShape
      slot_value: folder-per-subview
      subject_role: subview.view
      strip_suffix_from_vocabulary: SubviewViewSuffix
      append_suffix_from_vocabulary: SubviewConfigurationSuffix
    enforcement: lint
    rationale: testability
    check: >
      A `private var x: some View` inside a scene is a component in disguise. If it renders a
      THING  -  a switcher, a chip row, a banner, a bar, a legend, a card  -  extract it to its own
      file under the screen's component directory (the overlay's `ComponentsDir`, conventionally
      `Presentation/Components/`), as a `struct` that takes DATA and
      CALLBACKS, never the view model, and give it a `#Preview`.
      The directory's NAME is a dialect choice and not a finding on its own; using two names for
      it within one module is.
      Taking data instead of the view model is what makes the preview possible at all: a
      component holding a view model needs the DI container a canvas preview never configures,
      and the workaround (a preview-only fixture type shipped in production sources) is itself
      a finding.
      What stays a `@ViewBuilder` on the scene: the composition itself  -  the piece that orders
      the components, branches on loaded / error / empty, and reads the view model to decide
      WHICH component shows. That is business-rule display and it belongs with the screen.
      The test is what the fragment DOES, not how long it is. A row of icon + two labels + a
      button is a component even at ten lines; a three-line `if loaded { a } else { b }` is
      composition. A fragment that renders a thing AND takes no parameters is still a component -
      "it needs no arguments" means the data is hardcoded or read off the view model, and both
      are reasons to extract, not to keep it.
      A component the design system already owns (the shared UI package) needs no wrapper and
      no preview here; render it inline and let the design system own its previews.

  - id: READ-04c
    title: A business rule is a view-model member; the view reads it, never computes it
    severity: important
    enforcement: judgement
    rationale: testability
    check: >
      A `private var x: Bool/String/Int` in a scene that reads the view model and decides
      something  -  "is the header shown", "which leg is active", "what does the CTA say"  -  is a
      business rule in the view layer. It belongs on the view model, in its business-rules
      section, where a unit test can reach it. The scene reads `viewModel.x`.
      Not this rule: building a design-system `Configuration` value. Moving those to the view
      model drags UI types across the boundary; they belong inside the component that renders
      them (READ-04b), which is where they disappear once the component takes data.
      The view model is where such a rule GOES BY DEFAULT, and for a single-screen rule that is
      the end of it. When the same question turns out to be several screens', SVC-08's ladder
      decides between the entity and a named rule namespace (RULE-01); moving it out of the view
      layer is this rule, choosing its final home is that one.
      Also not this rule: reading the load state's own shape (NAME-06). `state.isLoading` decides
      nothing  -  it reports what the enum already says.

  - id: READ-04d
    title: A pure transform is a shared helper, not a method on the view
    severity: important
    enforcement: judgement
    rationale: flexibility
    check: >
      A function in a scene that touches no view-model state and returns no view  -  a date
      format, a duration split, initials from a name, a unit conversion  -  is a value transform.
      It belongs in the module's formatter / extension home (`Common/Formatters`, a typed
      `X+Extension`), not on whichever screen needed it first.
      The tell is duplication: the same `initials(_:)` written once in a scene and again in a
      mapper is the normal outcome of leaving these where they were typed. Move it on the first
      sighting, not the second.
      **A domain-local formatter type is the second choice, not the first.** Search the shared
      layer BEFORE writing one: a date/number/string transform with no domain vocabulary in it
      is not a domain concern, and the app already has a home for it (`CoreExtensions`,
      `CoreFormatters`). Grep the behaviour, not the name  -  the existing helper will be spelled
      differently (`Date.localizedBFFDate` vs a hand-rolled `displayDate`), and a per-domain
      `XFormatters` full of re-implementations of it is how three domains end up with three
      subtly different renderings of one contract field. When the shared helper is 90% right,
      extend IT (a new `Format` case, a defaulted parameter) rather than forking it.
      A domain-local formatter type is correct only when it adds domain vocabulary on top of the
      shared primitives  -  naming which two formats a screen's pickers exchange, for instance -
      and implements no formatting of its own.
      Not this rule: a function that builds a design-system `Configuration` from screen data.
      That is the component's own lowering and belongs inside the component (READ-04b).

  - id: READ-05
    title: Multi-line signature style  -  open paren at line end, one parameter per line
    severity: suggestion
    enforcement: format
    mechanism: swiftformat wrapArguments
    rationale: readability

  - id: READ-06
    title: One extension per protocol conformance
    severity: suggestion
    enforcement: judgement
    rationale: readability

  - id: READ-07
    title: Casing, boolean prefixes, guard-early / return-early
    severity: suggestion
    predicate: naming_pattern
    params:
      glob: '**/*.swift'
      declaration: '^\s+(?:@\w+\s+)?(?:private\(set\)\s+)?(?:public\s+)?var\s+([a-z]\w*)\s*:\s*Bool\b'
      accept_from_vocabulary: BooleanPrefixes
    enforcement: lint
    mechanism: swiftlint identifier_name, type_name, cyclomatic_complexity
    rationale: readability

  - id: READ-08
    title: Forbidden constructs  -  force unwrap, force cast, IUO, magic numbers, raw colors/fonts, print
    severity: blocking
    enforcement: lint
    mechanism: swiftlint force_unwrapping, force_cast, force_try, implicitly_unwrapped_optional; custom no_print, no_raw_hex, no_raw_font
    rationale: security

  - id: READ-09
    title: Standard file header  -  own target module, and the author's FULL git identity
    check: >
      The author line carries the identity exactly as git records it, department included
      ("NAME - <Department> Mudurlugu"). A bare name does not say which team owns the file, and
      the same person appears under several spellings once the department is dropped.
    severity: suggestion
    enforcement: lint
    mechanism: swiftlint file_header
    rationale: readability

  # ── STRUCT  -  project structure ────────────────────────────────────────────
  - id: STRUCT-01
    title: One primary top-level type per file; nesting only for owned details
    severity: important
    predicate: naming_pattern
    params:
      glob: '**/*.swift'
      declaration: '^(?:public\s+)?(?:final\s+)?(?:struct|class|enum|actor|protocol)\s+(\w+)'
      match_file_stem: true
    enforcement: lint
    rationale: readability
    check: >
      Forbidden nested: entity, domain model, request/response payload, list element. Allowed
      nested: a configuration/style/state helper with exactly one owner and no second reference
      site; a nested type a MACRO or an external design mapping owns  -  a `@FormSection`-generated
      `X.Section`, a Code Connect-mapped `X.State`  -  where flattening silently renames a
      contract the generator or the design file still refers to (the compiler catches the macro
      case, the design mapping fails silently); and a pure constants namespace  -  a caseless enum
      whose members are only `static let`
      literals (`AppConstant.Phone.defaultDialCode`), where the nesting IS the grouping and
      flattening to `AppConstantPhone` buys nothing. Decide by reference count, never by keyword.

  - id: STRUCT-02
    title: A screen is a known file manifest, not a pile
    severity: important
    predicate: dir_required_in_dir
    params:
      slot: ScreenLayerShape
      slot_value: layered
      vocabulary_key: LayerDirs
    enforcement: lint
    rationale: readability
    check: >
      Scene, ViewModel, AnalyticsTracking, UseCase, Repository (+protocol +mock), Mapper +
      models  -  each present when its responsibility exists. In a converted module the screen's
      Output enum lives contract-side, not here, and a module-local CoordinatorEvent or
      LocalizedText aggregator is pre-conversion residue reported as debt. Report a missing file
      whose responsibility leaked elsewhere AND a ceremonial empty file.

  - id: STRUCT-03
    title: Every screen sits at the same depth with the same internal grouping
    severity: important
    predicate: dir_required_in_dir
    params:
      vocabulary_key: PresentationDir
    enforcement: lint
    rationale: readability

  - id: STRUCT-04
    title: Nothing lives outside the layout  -  no loose root type, no Utils/Helpers/Misc bucket
    severity: important
    predicate: naming_pattern
    params:
      glob: '**/*.swift'
      declaration: '^(?:public\s+)?(?:final\s+)?(?:struct|class|enum|actor|protocol)\s+(\w+)'
      reject_from_vocabulary: ForbiddenTypeSuffixes
    enforcement: lint
    rationale: readability

  - id: STRUCT-05
    title: Type placement follows a consumer-count ladder
    severity: important
    predicate: prefix_collision
    params:
      subject_role: shared.root
    enforcement: lint
    rationale: flexibility
    check: >
      2+ modules -> cross-module shared tier. 2+ screens -> module shared entities. One screen ->
      that screen's own domain folder, in the sub-folder its KIND belongs to: an enum in
      `Domain/Enums/`, a value object in `Domain/Entities/`. A screen-behaviour enum (NAME-05) is
      a domain type, not a presentation one  -  it does not live beside the Scene.
      Both over- and under-hoisting are findings: a single-consumer type in the shared tier
      inflates the shared surface and reads as load-bearing when it is not.

  # ── NAME ──────────────────────────────────────────────────────────────────
  - id: NAME-01
    title: One name per role  -  a screen's navigation exit has one spelling module-wide
    severity: blocking
    enforcement: lint
    mechanism: >
      custom regex scoped to Scene/ViewModel, generated from the module's bound `NavExitShape`:
      any navigation-exit property or parameter whose name does not match the bound spelling.
    rationale: readability
    applies_when: >
      the module's overlay binds `NavExitShape`. Unbound, this rule is DISABLED  -  a regex for one
      spelling would report every module that chose the other one, which is a dialect difference,
      not a defect.
    check: >
      What this rule protects is that a reader finds a screen's exits under ONE name across the
      module. The settled shape is an `Output` enum consumed through a plain closure, with the
      enum living in the flow-contracts target. A `CoordinatorEvent` enum with a handler alias
      is the pre-conversion spelling: legitimate only in a module that has not converted yet, a
      debt finding in one that has, and never a choice for new code. The hard finding is a module
      that uses both, or a screen whose exits are spread across an enum and loose ad-hoc
      callbacks. Report the spelling per screen and the count of each.

  - id: NAME-02
    title: Our models use RequestModel/ResponseModel; transport suffixes stop at the data layer
    severity: blocking
    enforcement: lint
    mechanism: 'custom regex: Dto\b in Presentation paths'
    rationale: flexibility

  - id: NAME-03
    title: Module naming scheme  -  forbidden affixes come from the module's own conventions doc
    severity: important
    enforcement: lint
    mechanism: custom regex per module (Fetch prefix, Flow suffix, Manager/Helper/Util where banned)
    rationale: readability

  - id: NAME-04
    title: Fixed value sets are enums with tolerant decoding, not raw strings
    severity: blocking
    enforcement: judgement
    rationale: security
    check: >
      An unrecognised server value must land on a known-unknown case rather than failing or
      silently carrying an arbitrary string into the UI.
      **When the generator declares the same value set several times**  -  one inline enum per
      response payload, because that is what the schema says  -  do not pick one of them as the
      domain type and do not add a `switch` per mapper. Declare the domain enum once and bridge
      through the shared raw code, in an extension on the domain type
      (`extension X { init?(wireCode: String) }`) that lives beside it. Every mapper then maps
      through one initialiser, the unknown value lands in one place, and adding a case is one edit
      rather than one per payload. The bridge is a lowering, so it stays a mapper concern and does
      not put the generated types in front of the view model (SVC-04).
      Report per value set: how many generated declarations exist, how many hand-written mappings
      of it the module has, and whether the unknown value is handled the same way in each.

  - id: NAME-05
    title: Screen behaviour is driven by an enum state, not by a spread of booleans
    severity: important
    enforcement: judgement
    rationale: readability
    check: >
      Where the wire already carries the state (a status, a redirect, a form type), map it into
      an enum and branch on that  -  NAME-04 covers the decoding side. Where it does not, but the
      screen still renders materially different variants, declare the enum ON THE SCREEN and
      derive it from whatever inputs decide it. A variant set spread across three or four
      independent booleans is the finding: nothing states which combinations are legal, the
      reader has to enumerate them by hand, and `switch` stops telling the compiler to check
      exhaustiveness.

      Two shapes qualify as a finding:
      1. Three or more booleans read together to decide one visual outcome.
      2. A boolean pair whose illegal combination is only prevented by call order.

      The enum belongs to the view model as a derived value, not to stored state  -  deriving it
      keeps a single source of truth, whereas a stored copy drifts from the inputs it mirrors.
      Two independent booleans that never interact are fine; do not enum-ify for its own sake.

  - id: NAME-06
    title: The screen's load state is one enum with its payload attached, and the absent case is explained
    severity: important
    enforcement: judgement
    rationale: readability
    check: >
      NAME-05 says a screen's behaviour is an enum rather than a spread of booleans. This rule is
      about the LOAD state specifically  -  the one every screen with a service call has  -  and about
      the shape, because the shape is what stops the booleans growing back.
      - **The payload hangs off the case, not beside it.** `case loaded(Content)` where `Content`
        is its own struct. The alternative  -  an `isLoading` flag next to an optional `data` next to
        an optional `errorMessage`  -  permits `isLoading == true` with data present and an error
        set, a combination nothing in the type rejects and every reader has to reason about.
      - **The enum answers questions; the view does not destructure it.** Accessors on the enum
        (`var isLoading`, `var <entity>`) keep `if case let` out of the view body. These are not
        business rules (READ-04c)  -  they are reads of the state's own shape.
      - **A case deliberately NOT modelled is stated in the file, with its reason.** "There is no
        `empty` case: a successful response always represents a populated record; an empty payload
        arrives as a not-found error." Without that line the next reader cannot tell a considered
        omission from an oversight, and adds the case defensively.
      - **A secondary source that may fail is an optional slot inside the payload, not a second
        state.** When a screen's primary data loads but a supporting fetch fails, the failure
        belongs in `Content` as an optional the view simply does not render  -  not as a screen-level
        error that discards the data that did arrive. Say so at the property: what nil means and
        why it is non-fatal. Promoting a non-critical failure to a screen error is the finding, and
        so is silently defaulting it to an empty value, which makes "absent" and "empty" the same.
      **The measurement:** count the mutually-dependent state properties the view reads to decide
      what to render. Three or more that must be read together, or any pair whose illegal
      combination the type permits, is the finding.

  - id: STRUCT-07
    title: A scene is one type  -  no inner view struct wrapping it
    severity: important
    predicate: forbidden_member
    params:
      slot: ScreenCompositionShape
      slot_value: extracted
      subject_role: screen.entry
      pattern: '^\s+(?:@ViewBuilder\s+)?(?:private\s+)?(?:var|func)\s+(\w+)[^\n]*?some View'
      allow: [body]
    enforcement: lint
    rationale: readability
    check: >
      `struct XScene: View { var body: some View { XView(viewModel: viewModel) } }` with the real
      body in a second struct in the same file is two types where the reader expects one: the
      scene's name is the one in the coordinator and the file, and everything about the screen
      should be under it. Fold the inner view's body and state into the scene. The exception is a
      genuinely reusable view with its own consumers  -  and that one belongs in
      `Presentation/Components/` under READ-04b, not beside the scene.

  - id: STRUCT-08
    title: Each live implementation sits in its own file beside its protocol
    severity: important
    enforcement: judgement
    rationale: readability
    check: >
      `XUseCase.swift` holds the protocol; `XUseCaseLive.swift` sits next to it. Same for a
      repository. A reader opening the protocol sees the contract without scrolling past an
      implementation, and the implementation file is where the collaborators are declared.
      **The split earns its keep when there is an implementation to scroll past.** A repository, or
      any implementation with a body  -  request construction, mapping, cache reads, error
      projection  -  is split: that is the case the rule is for. A use case that is a pure
      pass-through, whose whole body forwards one call to one collaborator, may keep the protocol,
      the live type and its null object (TEST-08) in one file: there is no contract to scroll past,
      and splitting a dozen of those produces three dozen files whose names differ by a suffix.
      Decide by whether the implementation has anything a reader must skip, never by the layer's
      name  -  and apply one answer across the module, because the cost of this rule is a reader
      guessing which file a type is in.
      A pass-through use case is worth a second look for a different reason: SVC-01 asks what it
      adds over calling the repository directly. That is a separate finding from this one.

  - id: STRUCT-06
    title: A validation rule is a shared type until measurement says otherwise
    severity: important
    enforcement: judgement
    rationale: flexibility
    check: >
      Form/validation rules are the classic silent duplication: each one is small enough to
      re-type in ten seconds, so every module does, and the copies then drift. Before adding one,
      grep the shared rule library AND every sibling module for the CONCEPT, not the name  -  the
      same rule appears as `EmailFormatRule` / `EmailRule`, `TCKNRule` / `TCKNChecksumRule` /
      `NationalIdRule`, `PnrRule` / `PnrOrETicketFormatRule` / `<Screen>PnrOrETicketFormatRule`.
      Name-matching alone finds none of those.

      Placement follows STRUCT-05, with one addition: the error copy is NOT part of the rule.
      A rule takes its error as an init parameter, so one shared implementation serves every
      module while each keeps its own localized message  -  that is what makes hoisting cheap.

      Report per concept: implementations · owning modules · whether the shared library already
      has one · and any BEHAVIOURAL divergence between copies. Divergence outranks the
      duplication itself: two same-named rules that accept different input mean a value valid on
      one screen is rejected on another, and nothing in either module says so. Merging them is a
      behaviour decision needing its own tests, never a silent move.

  # ── SVC  -  service surface ─────────────────────────────────────────────────
  - id: SVC-01
    title: One request model in, one result out
    severity: important
    enforcement: judgement
    rationale: readability
    check: >
      A boundary method past ~2 parameters is a finding  -  the parameters want to be a request
      model. async, never a completion handler. No throws alongside the result family. Naming
      is governed by SVC-07, which overrides "pick a domain verb" for anything that wraps a
      single service call.

  - id: SVC-02
    title: Protocol, live implementation and test double share one signature
    severity: important
    enforcement: judgement
    rationale: testability

  - id: SVC-03
    title: The call site reads as one awaited statement, handled in the data-loading section
    severity: suggestion
    enforcement: judgement
    rationale: readability

  - id: SVC-04
    title: Generated networking is never hand-edited; generated types are touched only in the mapper
    severity: blocking
    enforcement: lint
    mechanism: CI diff check on the generated path + custom regex for generated type names outside Mapper
    rationale: flexibility

  - id: SVC-05
    title: One failure vocabulary per module, built through one factory  -  outcomes may be screen-scoped
    severity: blocking
    enforcement: judgement
    rationale: testability
    check: >
      Two things used to be one rule here, and conflating them pushed modules toward the wrong fix
      in both directions. Separate them:
      **The failure vocabulary is module-scoped.** The set of ways a call can fail  -  offline,
      timeout, unauthorised, not-found, conflict, server, unknown  -  is one type the whole module
      shares, produced by one factory that projects the transport error onto it. This is the
      blocking half. The tell that it was skipped: the same offline / timeout / server triple
      hand-written into a dozen screen-local types, each classifying the transport error again, so
      a new failure kind means a dozen edits and the classifications drift apart. Count the
      declarations of the same failure kind across the module; more than one home for the same
      concept is the finding.
      **The outcome type may be screen-scoped.** SVC-09 allows a screen to declare its own closed
      outcome enum, whose success cases carry that screen's destinations. That is not a second
      error family and does not violate this rule  -  provided the failure cases PROJECT FROM the
      module vocabulary rather than redeclaring it. A screen-scoped outcome that reaches for the
      transport error directly, or invents its own failure spelling, is the violation; one that
      names the shared kinds is correct.
      The distinction in one line: **how a call can fail is a module fact; what a screen does next
      is a screen fact.**

  - id: SVC-06
    title: Spec discipline  -  nothing hand-written around the generator, no endpoint in two specs
    severity: important
    enforcement: scan
    rationale: flexibility

  - id: SVC-07
    title: A method that wraps one service call is named after that service
    severity: important
    enforcement: lint
    mechanism: custom regex  -  non-`send` funcs in repository protocols surface for an explicit marker
    rationale: readability
    applies_when: >
      the module's overlay binds `ServiceNamingScheme: send-path`. Unbound, or bound to
      `domain-verb`, this rule is DISABLED for the module  -  see module_overlay_slots. The scheme is
      a module-wide decision because its whole value is one spelling on both sides of the wire;
      what the rule then enforces is internal consistency with the declared scheme, never
      conformity to a sibling module's choice.
    check: >
      `send` + the endpoint path, segments in their own order, camel-cased, path parameters
      dropped: `check-open-status` → `sendCheckOpenStatus`, `order/items/save` →
      `sendOrderItemsSave`, `session/extend` → `sendSessionExtend`,
      `lookup/{airlineAlliance}/airline` → `sendLookupAirline`.
      The path is the name, not the generated client method. A generator prefixes GET
      endpoints with `get` and appends parameter suffixes (`getSeatMapPageInfo`,
      `getAirlineListByAirlineAlliance`)  -  both are artifacts of the generator, and `get`
      also reorders the segments the backend chose. Copying the generated name silently
      renames the service; copying the path keeps one spelling on both sides of the wire.
      The HTTP verb is never part of the name: the endpoint already implies it, and `send`
      already says a request leaves the device. The name
      carries down the whole chain  -  repository protocol, live implementation, mock, test
      double, and any use case that is a straight pass-through  -  so one grep from the
      endpoint reaches every layer that touches it, and a reader of the call site knows
      which service will fire without opening the repository.
      Transport verbs invented by the client (`fetch...`, `load...`, `get...`) are the violation:
      they name what the code does, which the signature already says, instead of what the
      backend is being asked, which nothing else says.
      Two exceptions, and both must be visible:
      - **Several methods over one service.** When screen-scoped variants share a call
        (three country pickers filtering one `lookup/country`; an OTP path and a no-OTP path
        over one `add-fqtv-number`), the single private caller takes `send<ServiceName>` and
        the variants keep their screen names above it.
      - **No service behind it yet.** A placeholder whose endpoint does not exist keeps its
        domain name.
      Both carry `standard:exception(SVC-07)` with the reason, so the exception is a line in
      the file rather than an inconsistency a reader has to explain to themselves.
      A use case that aggregates, transforms, or merges sources is NOT a pass-through and
      keeps its domain name (SVC-01).

  - id: SVC-08
    title: A business rule lives in the view model or on its own struct  -  nowhere else
    severity: important
    enforcement: lint
    mechanism: custom regex  -  arithmetic and conditional expressions inside Mapper paths
    rationale: testability
    check: >
      There are exactly three homes for a business rule, and RULE-01 carries the ladder that
      picks between them: the **view model** that owns the behaviour; a **computed property on
      the struct the rule is about** (under a `// MARK: - Derived`), when more than one screen
      asks the same question of data the struct already holds; or a **named rule namespace**
      (RULE-01), when several screens share a rule that is screen policy rather than entity
      state. Every other layer is transport. A mapper, a `UseCaseLive`, a repository, a DTO mirror, a
      coordinator, a data source  -  each moves values between two shapes and decides nothing. A
      `UseCaseLive` in particular reads as a tempting home because it already knows the domain
      vocabulary: it does not get one. Its body is call → map → return, plus cache read/write;
      the moment it branches on a status code, merges two responses by a rule, or defaults a
      value nobody can derive from the payload, that rule has been hidden from the view model
      that lives with it and from every test that would have covered it.
      A mapper lowers one shape onto another and nothing else. Unwrapping an optional wire
      field to its empty value (`?? ""`, `?? false`, `?? []`) is lowering and is fine  -  the
      domain type is non-optional and the wire type is not.
      These are NOT lowering, and each one belongs to the view model (or, when several screens
      share it, a named domain rule the view models call):
      - **Arithmetic and unit conversion.** `TimeInterval(ms) / 1000`, a currency scale, a
        percentage. Carry the wire value with its unit in the name (`sessionTimeoutMs`) and
        convert where it is read.
      - **A clamp or a threshold.** `max(0, remaining)`, `count > 1`, "negative means none".
        A past deadline meaning "no countdown" is a product decision, not a cast.
      - **Choosing a screen state.** Resolving a variant, an enum case, or an error message
        from a combination of flags. Carry the flags; decide in the view model.
      - **A policy default.** `?? 180` seconds, `?? passengers.count`. A number nobody can
        derive from the payload is policy and belongs next to the code that lives with it.
      - **Dropping or promoting records.** Filtering a code out of a list, or turning one code
        into a different field, encodes what that code MEANS. That meaning belongs on the
        entity when several screens read it, and in the view model when one does.
      Why the mapper is the wrong home: it is the one type with no screen context, and a rule
      buried in it is invisible from the view model that owns the behaviour, untestable without
      constructing a DTO, and silently duplicated the next time another screen needs it.
      A shape read the mapper legitimately makes: choosing which array element the requested
      index refers to, or grouping rows by a key. It moves values into position; it does not
      decide what they mean.
      When the SAME lowering appears in more than one screen's mapper, it does not become a
      shared mapper type in a folder of its own  -  it moves onto the entity it produces, as an
      `init(_ dto:)` in an extension under a `// MARK: - Wire Mapping` in the entity's own file.
      The entity is where a reader looks for "how is this built"; a `FooMapper` parked at the top
      of the module is a second place to look and belongs to no screen.

      **Moving a rule OUT of a mapper is two edits, not one.** The reason these rules survive
      review is that deleting the decision also deletes the data it was computed from: the mapper
      stored `isFailure: Bool` and dropped `info.status`, so no later layer can re-derive it.
      So: first make the entity CARRY the raw inputs the decision reads (`info: Info?`,
      `hasPrevention: Bool`, `apisCompletions: [Bool]`), then express the decision as a computed
      property. Where that property lives:
      - **On the entity**, under a `// MARK: - Derived`  -  when more than one screen, or the
        screen AND its mocks, ask the same question. This is also what keeps the mock
        repositories honest: a mock can no longer hand-set a state the real payload could never
        produce, because the state is computed from the same inputs in both.
      - **In the view model**  -  when the question is that one screen's, or when the answer needs
        anything the entity does not hold (localized copy, a feature flag, live user state).
      - **In a named rule namespace** (RULE-01)  -  when several screens share the rule but it is
        screen policy rather than entity state, so putting it on the entity would make a data
        carrier answer a question about presentation. RULE-01 carries the selection ladder.
      Never in the mapper, and never as a stored field the mapper fills, because a stored field
      is indistinguishable from a wire value at every call site that reads it.

      Auditing a module for this is a grep, but a noisy one. These MATCH the search and are NOT
      violations  -  do not "fix" them: `init(x: T? = nil)` default-nil signatures, `?? ""` /
      `?? false` / `?? []` lowering of an optional wire field onto a non-optional domain one,
      `.first { $0.index == requested }` and `Dictionary(grouping:)` shape reads, and
      `.filter { !$0.isEmpty }.joined()` name assembly. What IS a violation: a comparison that
      names a code or a state (`== "TK"`, `== .error`, `status ?? "USABLE"`), a conditional that
      emits or drops a field, an aggregation whose result is a screen state
      (`contains { ... } ? .invalid : .valid`), and any localized string.

  - id: SVC-09
    title: A service outcome is a closed enum  -  a failure is a value, not control flow
    severity: important
    enforcement: judgement
    rationale: testability
    check: >
      A boundary method returns ONE type that enumerates every outcome the caller must handle,
      and the caller `switch`es over it exhaustively. The compiler then answers "is any outcome
      unhandled?", which no `catch` can. SVC-01 already bans `throws` alongside a result family;
      this rule says what the family looks like.
      Three properties make it work, and each is a separate finding when missing:
      - **Total.** Success, business failure, and every transport failure the screen renders
        differently are cases of the same type. A method that returns a result AND throws has two
        outcome channels, so no `switch` is exhaustive.
      - **Projected, not leaked.** The transport error family (the networking package's own error
        type) is projected onto the screen's outcome inside the data layer, through one shared
        projection helper rather than a per-repository `switch`. The transport type never appears
        in a Domain or Presentation signature (FLEX-05). A repository that returns the transport
        error verbatim has moved the classification into every caller.
      - **Named after the outcome, not the payload.** A success case may carry a DESTINATION
        rather than data  -  a redirect the backend chose, resolved from the wire value into a case
        (NAME-04) so no raw string reaches the view model. That is the shape's main advantage over
        a bare `Result<Payload, Error>`, and it is why the two coexist.
      Where the error spine lives is SVC-05's question, not this one: the outcome enum is
      screen-scoped, the failure vocabulary it projects from is module-scoped. Duplicating the
      same offline / timeout / server triple into each screen's enum is the SVC-05 finding.
      **The measurement, for a finding to be one:** per boundary method, the outcome-channel count
      (a result type plus a `throws` is two), the number of call sites that re-classify a transport
      error, and whether any non-exhaustive `switch` over the result exists. No count, no finding.

  # ── RULE  -  business-rule surface ──────────────────────────────────────────
  - id: RULE-01
    title: A shared business rule lives in a named rule namespace, as a pure static, traceable to its source
    severity: important
    enforcement: judgement
    rationale: testability
    check: >
      SVC-08 names three homes for a business rule. The ladder that picks between them, cheapest
      rung first:
      1. **One screen asks it** -> the view model. Stop here. Do not create a namespace for one
         caller; a namespace with a single consumer is over-hoisting (STRUCT-05).
      2. **Several screens ask it, of data the entity already carries** -> a computed property on
         the entity, under `// MARK: - Derived`. This is also what keeps test doubles honest: a
         double can no longer hand-set a state the real payload could not produce.
      3. **Several screens ask it, but the answer is screen POLICY rather than entity state** ->
         a named rule namespace. Putting it on the entity would make a data carrier answer a
         question about presentation; leaving it in one view model hides it from the others.
      The namespace's shape, and each property is what makes it auditable:
      - A **caseless enum** (never a struct with an initialiser, never a class), so it cannot be
        instantiated or hold state.
      - **Static, pure functions.** Inputs in, value out. No stored state, no I/O, no logging, no
        localized copy. A rule that needs the environment takes it as a parameter  -  a clock as a
        defaulted parameter is the accepted seam, and it is what keeps TEST-01 satisfied without
        an injected container for a value transform.
      - It lives in the module's **domain layer**, beside the entities it reads, not beside the
        scene that needed it first.
      - It is **not** a formatter, a mapper, or a validator. A value transform is READ-04d, a
        shape lowering is SVC-08, a form rule is STRUCT-06. A namespace that accumulates all four
        is a Utils bucket with a better name (STRUCT-04).
      **Traceability is part of the rule, not a nicety.** Each function names the requirement it
      enforces  -  the spec clause, business-rule ID, or ticket  -  in its doc comment, and the test
      that covers it repeats that same token in its name. One grep then reaches requirement, code
      and test. This is also the rule's own measurement: a namespace whose functions cite nothing
      cannot be checked against anything, and a cited rule with no test of the same token is an
      untested requirement, which is the finding.
      **What the doc comment is for.** Recording the decision  -  which input the rule keys off and
      why the obvious alternative was rejected  -  is exactly the case READ-02 protects; it is not
      trimmed. This rule does not require such a comment, it requires the citation.

  - id: PLAT-01
    title: Platform affordances go through the app's own wrapper, not the OS API
    severity: blocking
    enforcement: lint
    mechanism: custom regex  -  raw SwiftUI presentation APIs outside the shared wrapper's own module
    rationale: flexibility
    check: >
      When the app ships a wrapper for a platform affordance  -  a sheet, a date picker, a toast -
      a feature module calls the wrapper, never the OS API underneath. The wrapper exists
      because the OS changed the affordance once already and will again: iOS 26 forced a
      Liquid-Glass inset onto partial-detent sheets with no opt-out, which put a gap down the
      side and bottom of every raw `.sheet` and swallowed a pushed picker's selection.
      The tell that this rule was skipped: one module's sheets look different from the rest of
      the app after an OS upgrade, and the fix already exists in the shared layer.
      Exception: an affordance the OS itself owns end to end  -  a share sheet, an Add-to-Siri
      sheet, a photo picker  -  stays on the system API. Wrapping those changes nothing and
      breaks their behaviour.

  - id: PLAT-02
    title: A self-sizing presentation only works if the content reports its size
    severity: important
    enforcement: judgement
    rationale: readability
    check: >
      A content-sized detent asks the content how tall it is. A plain geometry read inside the
      presentation answers with the frame the presentation already gave it  -  so the sheet
      latches onto its own current height and never grows. Either publish the height the way
      the framework expects (per region, the way the shared scaffold does) or use a fixed
      height. Do not mix: a content detent with no reporter is a sheet that opens at the wrong
      size in one locale and looks right in another.
      The mirror-image mistake is worse, because it looks like a fix: pre-sizing the content so
      it "reports a better height". A `fixedSize(vertical:)` at the presentation root measures
      the content against an unconstrained WIDTH proposal  -  every line of copy lays out on one
      line, and the height that comes back is a fraction of the real one. The sheet then opens
      at two lines tall with the text clipped. When the presentation already measures its
      content (an intrinsic-size host, a per-region scaffold), the content must add NOTHING:
      no `fixedSize`, no geometry read, no frame. Check which key the engine actually observes
      before publishing one  -  publishing to a key nothing reads is dead code that reads as a fix.
      A content-sized presentation is also only as good as WHEN it measures. If the panel reads
      the content's intrinsic size during the present animation  -  before the hosting view has its
      final width  -  copy that wraps reports a fraction of its real height and the sheet opens
      short. It cannot recover on its own when the follow-up measurement reads the RENDERED
      height, because the short panel is already clamping that: the wrong size is a fixed point
      until the user drags the sheet, which is exactly what the bug report will describe. The fix
      belongs in the presentation engine  -  one re-read after the transition settles  -  not in each
      caller. The tell that it is a measurement-timing bug and not a detent choice: a wheel picker
      in the same engine is fine, because its ideal height does not depend on width.
      A fixed detent is not the workaround either. `[.medium, .large]` on short content leaves
      dead space, and on tall content clips the primary button. If the content has a natural
      height, say so with a content detent and one shared cap.
      When a presentation moves to a different engine, DELETE the previous engine's modifiers
      from the content. `presentationDetents` / `presentationDragIndicator` /
      `presentationBackground` left on a view that a UIKit-hosted panel now presents are silent
      no-ops, and the next reader will believe the sheet is sized there and debug the wrong file.
      A content-sized panel asks its content one question: how tall do you want to be? The
      content must be able to answer it. Two answers that are not answers, and each produces the
      opposite symptom:
      - **A trailing `Spacer`** answers "all of it". The panel opens at its cap with the real
        content stranded at the top. A content-sized sheet's root stack has no `Spacer`  -  its own
        height IS the answer; use padding for the bottom clearance.
      - **Copy with no width** answers "one line per paragraph". Height that is entirely a
        function of where lines break cannot be measured without a width, so the sheet opens
        short and clips. Hand the presenting screen's measured width down to the content.
      Diagnose by comparing against a sheet in the same engine that works. If one is fine and
      another is not, the engine is not the bug  -  and a shared-engine change to fix one screen
      puts every other domain's sheets at risk for a fix nobody has verified. Fix it where the
      content is.
      When a presentation moves to a different engine, DELETE the previous engine's modifiers
      from the content. `presentationDetents` / `presentationDragIndicator` /
      `presentationBackground` left on a view that a UIKit-hosted panel now presents are silent
      no-ops, and the next reader will believe the sheet is sized there and debug the wrong file.
      A fixed detent is not the workaround either: `[.medium, .large]` on short content leaves
      dead space, and on tall content clips the primary button.
      Verify presentation sizing on a device or simulator before calling it done. This is not a
      thing a diff shows, and it is not a thing to guess at twice.

  - id: PLAT-03
    title: Assets come from the app's own CDN
    severity: blocking
    enforcement: lint
    mechanism: "custom regex: https?:// host not on the app's own domains, in a non-test source"
    rationale: security
    check: >
      An image or asset URL pointing at a third-party host is a finding even when it renders
      correctly: the host is outside our control and versioning, it can change or disappear
      under us, and every render leaks a request to a party the user never agreed to. The
      carrier logo, the flag, the banner  -  they all exist on the app's own CDN. Put the URL in
      the module's constants namespace so there is one place to change it.

  # ── SAFE ──────────────────────────────────────────────────────────────────
  - id: SAFE-01
    title: Escaping closures capture self weakly and unwrap immediately
    severity: important
    enforcement: lint
    mechanism: "custom regex: escaping closure body referencing self without a weak capture list"
    rationale: security

  - id: SAFE-02
    title: A calendar day sent to a service uses the shared calendar-day helper
    severity: blocking
    enforcement: lint
    mechanism: "custom regex: DateFormatter constructed in a mapper or repository path"
    rationale: security

  # ── VIS  -  declaration & visibility ────────────────────────────────────────
  - id: VIS-01
    title: Every class is final unless a real subclass exists in the module
    severity: important
    enforcement: lint
    mechanism: 'custom regex: ^\s*(public |internal )?class\b not preceded by final'
    rationale: readability

  - id: VIS-02
    title: Explicit access level, private by default; internal handled consistently
    severity: important
    enforcement: lint
    mechanism: swiftlint private_outlet, explicit_acl (opt-in)  -  scoped to the module's convention
    rationale: readability

  - id: VIS-03
    title: Mutability and conformance declared as narrowly as the type allows
    severity: suggestion
    enforcement: lint
    mechanism: swiftlint prefer_let, prefer_self_in_static_references
    rationale: testability

  - id: VIS-04
    title: The surviving public surface and every seam contract carries a doc comment
    severity: important
    enforcement: lint
    mechanism: swiftlint missing_docs scoped to public declarations
    rationale: flexibility
    check: The one place the no-unnecessary-comments rule inverts  -  a cross-module contract cannot explain itself through naming.

  # ── UI ────────────────────────────────────────────────────────────────────
  - id: UI-01
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI view composition  -  a UIKit view controller hierarchy expresses reuse differently
    title: A screen's composite view is never consumed by another screen
    severity: blocking
    enforcement: judgement
    rationale: flexibility

  - id: UI-02
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI view construction
    title: No new component is added to a UI target the module marks as frozen
    severity: important
    enforcement: lint
    mechanism: CI check on new files under the frozen target path
    rationale: flexibility

  - id: UI-03
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI view construction
    title: Every screen with user actions has its analytics surface; no direct generated-event calls
    severity: important
    predicate: file_required_in_dir
    params:
      role: screen.analytics
    enforcement: lint
    rationale: readability

  - id: UI-04
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI copy surface (LocalizedText); UIKit modules localise through a different seam
    title: All user copy goes through the screen's copy surface
    severity: important
    enforcement: judgement
    mechanism: "custom regex: raw localization key access inside Scene/ViewModel"
    rationale: flexibility

  # ── MOD  -  modularity ──────────────────────────────────────────────────────
    conflict: >
      The module-structure pass that produced STRUCT-09..14 came from a folder-structure document
      that says the opposite - copy is resolved AT THE RENDER SITE, and a per-screen copy type is
      the finding. One module has already been refactored to that shape and now has zero copy
      surfaces, which this rule reads as a violation on every screen. Two documents the same team
      owns disagree; until one is retired, this rule is not mechanically enforced. Do not resolve
      it by binding a slot: this registry's own slot doctrine names UI-04 as the counter-example -
      a module with no copy surface has not chosen a dialect, it is missing the surface.

  - id: MOD-01
    title: A feature module never imports another feature module
    severity: blocking
    enforcement: lint
    mechanism: "custom regex per module: ^import (<sibling feature names>)"
    rationale: flexibility
    check: A feature that imports many siblings to compose them has promoted itself to a second composition root  -  highest severity in this section.

  - id: MOD-02
    title: The manifest graph is one-way and acyclic; core never depends on a feature
    severity: blocking
    enforcement: scan
    rationale: flexibility
    check: Check as policy, not by trusting the build  -  a package-level cycle can still build. Report dead edges (declared, never imported).

  - id: MOD-03
    title: Shared values walk the placement ladder, cheapest rung first, with a stated reason to climb
    severity: important
    enforcement: judgement
    rationale: flexibility

  - id: MOD-04
    title: Removability delta  -  removing the module touches only the composition root
    severity: blocking
    enforcement: scan
    rationale: flexibility
    metric: "files touched to remove the module (target: composition root only)"

  - id: MOD-05
    title: DI is by abstraction; each module wires only itself
    severity: blocking
    enforcement: judgement
    rationale: testability
    check: >
      Resolving a concrete type owned by another feature module is a compile-time dependency in a
      runtime disguise. The composition root is the only place that knows the module list.

  - id: MOD-06
    title: The public surface is the plug  -  entry types only, everything else internal
    severity: important
    enforcement: scan
    rationale: flexibility
    metric: "public declarations divided by externally consumed symbols (target ~1)"

  - id: MOD-07
    title: No shared mutable state across the boundary; the module owns its own assets and copy
    severity: blocking
    enforcement: judgement
    rationale: flexibility

  # ── CONC  -  concurrency ────────────────────────────────────────────────────
  - id: CONC-01
    title: The isolation policy is one declared decision applied everywhere
    severity: important
    enforcement: judgement
    rationale: readability
    applies_when: module is in Swift 6 language mode (otherwise suggestion)
    check: A reader must know where a function runs from its declaration, without tracing callers.

  - id: CONC-02
    title: Sendable conformance is stated where it is load-bearing, consistently
    severity: suggestion
    enforcement: judgement
    rationale: readability

  - id: CONC-03
    title: Escape hatches are justified and counted
    severity: important
    enforcement: lint
    mechanism: 'custom regex: @preconcurrency|nonisolated\(unsafe\)|@unchecked Sendable'
    rationale: security
    metric: "escape-hatch count by kind (target: flat or falling)"
    check: A growing count silently returns the module to pre-Swift-6 guarantees while the build stays green.

  - id: CONC-04
    title: One concurrency model  -  no GCD, semaphore or completion handler layered onto async
    severity: important
    enforcement: lint
    mechanism: "custom regex: DispatchQueue|DispatchSemaphore|DispatchGroup in async-adopted paths"
    rationale: readability

  - id: CONC-05
    title: Every task has an owner and a cancellation story
    severity: important
    enforcement: judgement
    rationale: testability

  # ── TEST  -  testability by design ──────────────────────────────────────────
  - id: TEST-01
    title: The environment is injected, never reached for
    severity: blocking
    enforcement: lint
    mechanism: 'custom regex scoped to logic paths: Date\(\)|UUID\(\)|UserDefaults|\.random|Locale\.current'
    rationale: testability
    check: >
      Time, calendar, randomness, UUID, locale, storage, network, feature flags and session state
      arrive through an abstraction. A logic type calling these directly is untestable by
      construction  -  no amount of test-writing discipline fixes it.

  - id: TEST-02
    title: Business rules are callable without a view, navigation or network
    severity: important
    enforcement: judgement
    rationale: testability
    check: Decision logic returns a value rather than mutating far-away state.

  - id: TEST-03
    title: No static or singleton access from the logic layer
    severity: important
    enforcement: lint
    mechanism: 'custom regex: \.shared\b|\.main\.container outside composition/DI paths'
    rationale: testability

  - id: TEST-04
    title: Test doubles follow one named taxonomy  -  stub, spy, fake, mock, builder
    severity: suggestion
    enforcement: judgement
    rationale: testability
    check: >
      One kind per file, name states the kind, signature parity with the real type (SVC-02). All of
      them live in the test target (TEST-08).
      **The builder is the fifth kind and the one most often missing.** Where tests need many
      near-identical entities differing in one field, the alternative to a builder is a wall of
      full initialiser calls  -  so a test's actual subject drowns in fifty lines of scaffolding, and
      a new field on the entity breaks every test that never cared about it. A builder is a base
      value plus chainable single-field overrides returning a copy
      (`.<baseCase>().with<Field>(...)`), so each test states ONLY what it varies and reads as its
      own precondition. Two properties keep it honest: it produces the real type (never a
      parallel test-only mirror, which drifts), and each override sets one field, so a reader can
      tell what a test depends on from its chain.
      The finding is not "no builder exists"  -  it is a test file where the setup of a case is
      longer than its assertion, repeated. Report the ratio, not the absence.

  - id: TEST-05
    title: Async behaviour is testable  -  no unstructured task in logic, no sleep, injected clock
    severity: important
    enforcement: lint
    mechanism: 'custom regex: Task\s*\{ in logic paths; sleep\( in test paths'
    rationale: testability

  - id: TEST-06
    title: Tests mirror the source tree across the module's declared layers
    severity: important
    predicate: mirror_required
    params:
      test_role: test.root
      source_role: source.root
    enforcement: scan
    rationale: testability
    metric: "screens with production logic and no mirrored test folder (target 0)"

  - id: TEST-07
    title: The module's test target compiles with no sibling feature present
    severity: blocking
    enforcement: scan
    rationale: testability

  - id: TEST-08
    title: A null object may ship in production sources; a test double may not
    severity: important
    enforcement: lint
    mechanism: >
      custom regex  -  a type whose name marks it as a double (Mock / Stub / Spy / Fake / Fixture
      prefix or suffix) declared under the module's production source root rather than its test
      target. Whether a given production type is a null object or a disguised double is
      JUDGEMENT: the name is the signal a regex can see, the payload is not.
    rationale: testability
    check: >
      Two kinds of type conform to a protocol without doing the real work, and only one of them
      belongs in production sources.
      **A null object does.** It satisfies the protocol by doing nothing and returning the empty
      answer  -  no event sent, `nil` returned, an empty list. It is the honest default for a
      dependency the caller has legitimately not wired: a defaulted initialiser parameter, a
      canvas preview, a screen whose optional slot is not configured yet. It carries no data, so
      it cannot drift from the real payload, and it makes the seam visible in the DI graph instead
      of forcing an optional dependency on every call site. Name it so the reader knows what it is
      (a `Noop`-style prefix, consistently across the module) and let it be found by grep.
      **A test double does not.** A type that carries canned payloads  -  a mock repository
      returning a hand-built response, a fixture of sample records  -  ships that data to users,
      grows in the store binary, and can be resolved by accident because nothing but the name
      says it is not real. It belongs in the test target, where the compiler keeps it out of the
      app. Where a canned payload is genuinely needed AT RUNTIME (a demo build, an offline
      preview mode), that is a debug affordance and SEC-09 governs it: the question is what
      removes it from the store build, and "it is only referenced by the mock path" is not an
      answer.
      Distinguish by payload, never by name: a `Mock` that returns nothing is a null object with
      the wrong name (rename it), and a `Noop` that returns three sample bookings is a double in
      the wrong target (move it).
      **The measurement:** per module, the count of double-named types under the production source
      root, split into carries-payload and returns-empty. The first number is the finding; the
      second is a naming fix.

  # ── FLEX  -  intra-module flexibility ───────────────────────────────────────
  - id: FLEX-01
    title: Layers meet through protocols; no concrete cross-layer type in a signature
    severity: important
    enforcement: judgement
    rationale: testability

  - id: FLEX-02
    title: Variants are configuration, not branching
    severity: important
    enforcement: judgement
    rationale: flexibility
    check: A conditional whose branches differ only in tokens or copy is a finding.

  - id: FLEX-03
    title: Components are open for extension  -  a new variant does not edit the existing body
    severity: important
    enforcement: judgement
    rationale: flexibility

  - id: FLEX-04
    title: Feature flags are resolved at the boundary and carry a removal condition
    severity: important
    enforcement: lint
    mechanism: "custom regex: flag access inside a view body"
    rationale: flexibility

  - id: FLEX-05
    title: No layer's vocabulary leaks into another  -  presentation types never travel downward
    severity: important
    enforcement: judgement
    rationale: flexibility

  # ── SEC  -  security & privacy ──────────────────────────────────────────────
  - id: SEC-01
    title: Sensitive data persists only when it must, and then only in the Keychain
    severity: blocking
    enforcement: lint
    mechanism: >
      custom regex generated from the module's sensitive-data inventory, in two directions:
      (a) a resolved sensitive symbol written to UserDefaults, a plist, a file or the local
      database; (b) a symbol the inventory marks transient appearing in ANY persistence call,
      keychain included
    rationale: security
    applies_to_classes: all
    check: >
      Walk persistence_decision first. Most sensitive values in a flow are used and dropped -
      those stay in memory, and writing them to the Keychain is itself a finding, because an
      unnecessary keychain item outlives the flow, survives logout unless someone deletes it, and
      creates a cleanup obligation with no owner. Only a value that must survive app restart is
      persisted, and then: Keychain only, with an explicit accessibility class matching the data
      (device-only unless a documented reason exists), no iCloud sync, biometric or passcode
      gating where policy requires. never-persist-locally classes stay transient regardless.
      Report both over-persistence and under-protection  -  they are equally findings.

  - id: SEC-02
    title: No hardcoded secrets  -  anything in source is treated as already leaked
    severity: blocking
    enforcement: lint
    mechanism: 'custom regex: (api[_-]?key|secret|password|bearer|private[_-]?key)\s*[:=]\s*"'
    rationale: security

  - id: SEC-03
    title: Logging never carries sensitive data; log interpolation defaults to private
    severity: blocking
    enforcement: lint
    mechanism: >
      custom regex generated from the module's sensitive-data inventory: any resolved symbol
      interpolated into a log call, plus a blanket ban on print( and on logging a raw
      request/response body
    rationale: security
    applies_to_classes: all
    check: >
      Every class marked loggable:never is redacted; membership-identity is hashed or truncated
      where it must appear at all. Interpolated values are private by default and only values
      explicitly known to be non-sensitive are made public  -  the default must fail safe, because
      the cost of a missed annotation is a logged credential.

  - id: SEC-04
    title: Transport is HTTPS and ATS-compliant; an exception needs a written reason and an expiry
    severity: blocking
    enforcement: lint
    mechanism: Info.plist ATS key scan + custom regex for http:// literals
    rationale: security

  - id: SEC-05
    title: Sensitive data has a lifetime
    severity: important
    enforcement: judgement
    rationale: security
    check: >
      Cleared on logout and session end; sensitive screens hidden from the app-switcher snapshot;
      pasteboard writes explicit and expiring; nothing sensitive cached to disk by default.

  - id: SEC-06
    title: Analytics and crash payloads are redacted, through a named helper, with a test that proves it
    severity: blocking
    enforcement: judgement
    rationale: security
    check: >
      Cross-check every analytics event's parameter list, user properties, breadcrumbs and
      non-fatal payloads against the module's sensitive-data inventory. A class marked
      `loggable: never` must not appear at all; one marked `hashed-or-truncated-only` appears only
      in its reduced form.
      Redaction that lives at the call site is not redaction  -  it is a habit, and it fails on the
      event somebody adds next month. Three properties, each a separate finding:
      - **One named helper does the reduction.** A hashing or truncating function with a name,
        callable from anywhere the value is emitted, so there is one implementation to review and
        one thing to grep. Not an inline expression repeated per event.
      - **The event surface carries only the reduced form.** The typed event (UI-03) declares the
        parameter as the hash or the truncation, never the raw value with a comment asking callers
        to reduce it first. A parameter that CAN hold the raw value eventually does.
      - **The redaction has a test.** At minimum: the output is deterministic for the same input,
        differs for different inputs, and does not contain the input. A redaction nobody tested is
        a claim; these three assertions are cheap and turn it into a property. This is the rule's
        measurement  -  an untested reduction helper is the finding even when the code is correct.
      Truncation needs one extra check that hashing does not: that what remains cannot identify
      the subject on its own, and cannot be joined against another field in the same event to do
      so. Two separately-harmless truncations in one payload are not harmless.

  - id: SEC-07
    title: Permissions are least-privilege with accurate purpose strings
    severity: important
    enforcement: scan
    rationale: security
    check: A permission requested but unused is a finding  -  a privacy problem and an App Review risk.

  - id: SEC-08
    title: The privacy manifest is complete and honest
    severity: blocking
    enforcement: scan
    rationale: security
    check: Declared data types match what the module actually collects; required-reason APIs carry a valid reason code.

  - id: SEC-09
    title: Debug and mock affordances are excluded from the store build by a real mechanism
    severity: blocking
    enforcement: lint
    mechanism: >
      custom regex for the commented-out compilation directive only (a "// #if" is not a guard -
      the code inside it ships). Whether an affordance is actually gated is JUDGEMENT: once the
      gate is a resolved capability rather than a compile-time condition, no regex can see it -
      a pattern that looked for "#if DEBUG nearby" flags the gated call site and the debug type's
      own declaration alike, which is noise, not signal.
    rationale: security
    check: >
      The rule is store-build exclusion, not "#if DEBUG" specifically. Internal, TestFlight and
      enterprise distributions may legitimately carry a debug menu; the store build must not. The
      question to answer per affordance is "what removes this from the store build?", and any
      mechanism that actually fires there is acceptable.

      Three failure shapes, all findings:
      1. No gate  -  the affordance is unconditionally compiled and reachable.
      2. A commented-out gate  -  "// #if DEBUG" reads like protection and provides none. Usually a
         symptom of shape 3 rather than carelessness.
      3. A gate that fires in the wrong builds  -  "#if DEBUG" in a Release-configured internal
         build strips the affordance from exactly the testers who need it, so someone eventually
         comments it out and it reaches the store. Diagnose this as a missing mechanism, never as
         "restore the directive": restoring it re-breaks the internal build.

      Know the constraint before proposing a fix. A SwiftPM module does not inherit the app
      target's SWIFT_ACTIVE_COMPILATION_CONDITIONS, and SwiftPM's .when(configuration:) predicate
      distinguishes only debug from release  -  a custom Xcode configuration is invisible inside the
      package. Where a module needs a three-way distinction (local / internal / store), a
      compile-time condition alone cannot express it.

      Prefer, in order: (a) the composition root resolves the distribution channel once and
      injects it as a capability the module consumes through an abstraction, so the module never
      knows how it was distributed  -  this also satisfies MOD-05; (b) a build-injected Info.plist
      flag read at startup; (c) a runtime channel probe such as the absence of an embedded
      provisioning profile. Whichever is chosen it must be ONE shared mechanism: one per module
      means nobody can answer "is this in the store build?" without reading every call site.

  # ── DEPR  -  deprecation debt ───────────────────────────────────────────────
  - id: DEPR-01
    title: No call site of a deprecated API  -  platform or in-repo
    severity: important
    enforcement: scan
    rationale: flexibility
    metric: "deprecated call sites (target 0, and never rising)"
    check: >
      Two sources, both count. Platform deprecations surface only in a build log, so harvest them
      from the build the module is actually verified with (WARN-01) rather than guessing. In-repo
      deprecations are greppable: find every `@available(*, deprecated)` declaration across the
      repo, then count this module's call sites of each.
      A deprecation nobody migrates is worse than no deprecation  -  it trains readers to ignore the
      warning, and it hides the one that matters. Report per deprecated symbol: declaration site ·
      replacement named in the message · this module's call-site count · migration owner.

  - id: DEPR-02
    title: Our own deprecations name a replacement and have a removal condition
    severity: important
    enforcement: lint
    mechanism: "custom regex: @available(*, deprecated) without a message:, and a deprecated declaration still referenced in-module"
    rationale: flexibility
    check: >
      A deprecation without a stated replacement is a complaint, not a migration. Every
      `@available(*, deprecated, message:)` says what to use instead and, in the plan, who owns
      the removal and when. A deprecated symbol with zero remaining call sites is deleted, not
      left as furniture.

  - id: DEPR-03
    title: Governance docs never prescribe a deprecated API
    severity: blocking
    enforcement: judgement
    rationale: readability
    check: >
      Cross-check the module's own docs against the deprecation list. When a doc mandates a
      pattern whose API is deprecated, every new screen built to the doc adds fresh debt, and the
      developer following the standard is punished for it. This outranks the individual call
      sites: fix the doc first, then migrate, or the count grows faster than the migration.

  - id: DEPR-04
    title: Availability gates below the deployment target are removed
    severity: suggestion
    enforcement: lint
    mechanism: "custom regex: @available / #available naming a version at or below the package deployment target"
    rationale: readability
    check: >
      An `if #available(iOS 16)` in a module that already requires iOS 17 is dead branching a
      reader must still evaluate. Forward gates (above the target) are legitimate and stay.

  # ── WARN  -  warning debt ───────────────────────────────────────────────────
  - id: WARN-01
    title: The module's build produces zero warnings, and the count never rises
    severity: important
    enforcement: scan
    rationale: readability
    metric: "compiler warnings for this module (target 0, hard requirement: not increasing)"
    check: >
      Harvest from the module's real verification build (an xcresult or a build log  -  the one the
      team actually runs, since some targets cannot be built from the CLI). Group by warning kind
      and report the top kinds, not just a total: one repeated warning across 40 files is a single
      fix, and a total hides that.
      Warnings are the canary for deprecation, concurrency and unused-code debt at once. A build
      with 300 warnings has no working warning channel  -  the next real one is invisible, which is
      the actual cost. Treat the count as a ratchet even when zero is out of reach today.

  - id: WARN-02
    title: A TODO carries an owner or a ticket, and no FIXME survives a release
    severity: suggestion
    enforcement: lint
    mechanism: "custom regex: TODO or FIXME without a bracketed tag or ticket reference"
    rationale: readability
    metric: "untagged TODO count (target 0), FIXME count (target 0)"
    check: >
      `// TODO[SWAGGER-208458421]: ...` is a tracked decision; a bare `// TODO:` is a note to a
      person who has left. Tagging is cheap and makes the debt countable. FIXME means "known
      broken" and belongs in the tracker, not the source.

  # ── A11Y ──────────────────────────────────────────────────────────────────
  - id: A11Y-01
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI accessibility modifiers
    title: An identifier from the shared source on every interactive element
    severity: important
    enforcement: lint
    mechanism: custom regex for interactive modifiers without an accessibility identifier
    rationale: testability

  - id: A11Y-02
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI accessibility modifiers
    title: Localized VoiceOver label, plus a hint where the action is not obvious
    severity: important
    enforcement: judgement
    rationale: accessibility

  - id: A11Y-03
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI accessibility modifiers
    title: Minimum 44x44 tap target; grouped content exposes one meaningful element
    severity: important
    enforcement: judgement
    rationale: accessibility

  - id: A11Y-04
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI accessibility modifiers
    title: Dynamic Type does not break layout at the largest accessibility sizes
    severity: important
    enforcement: judgement
    rationale: accessibility
    check: No fixed-height container holding scalable text.

  - id: A11Y-05
    scope:
      {
        frameworks: [swiftui],
        paths:
          [
            "**/*View.swift",
            "**/*Screen.swift",
            "**/*Scene.swift",
            "**/*Cell.swift",
            "**/*Configuration.swift",
            "**/*+Modifiers.swift",
          ],
      }
    scope_reason: SwiftUI accessibility modifiers
    title: RTL mirrors correctly; no leading/trailing hardcoded as left/right
    severity: important
    enforcement: lint
    mechanism: 'custom regex: \.left|\.right in alignment and padding edges'
    rationale: accessibility

  # ── PERF  -  minimal set; the boundary is declared in STANDARD.md ───────────
  - id: PERF-01
    title: No expensive computation inside a view body
    severity: important
    enforcement: judgement
    rationale: performance

  - id: PERF-02
    title: Lists and grids use lazy containers with stable identity
    severity: important
    enforcement: lint
    mechanism: "custom regex: ForEach over indices as identity; VStack over a large collection"
    rationale: performance

  - id: PERF-03
    title: No blocking work at init or on the main actor  -  decoding, file I/O, JSON
    severity: important
    enforcement: judgement
    rationale: performance

  - id: PERF-04
    title: No per-render construction of formatters, calendars or regexes
    severity: suggestion
    enforcement: lint
    mechanism: "custom regex: DateFormatter(|NumberFormatter(|Regex( inside a view body"
    rationale: performance

  # --- Tree, mechanically checked (added 0.2.0) ----------------------------
  - id: STRUCT-09
    title: A service operation's request and response live together, and neither travels alone
    severity: important
    enforcement: lint
    predicate: pair_required_in_dir
    params:
      slot: ServiceModelDir
      container_role: service.dir
      left_role: service.request
      right_role: service.response
      pairing_slot: ServiceModelPairing
      right_only_value: response-only
    rationale: navigability
    check: >
      A directory holding one half of a service operation holds the other. A lone response means
      the request is inlined somewhere a reader will not find it. Under `response-only` a lone
      response is normal and only a lone request is the finding.

  - id: STRUCT-10
    title: A screen ships the construction seam its module's assembly shape declares
    severity: important
    enforcement: lint
    predicate: file_required_in_dir
    params:
      slot: ScreenAssemblyShape
      slot_value: per-screen-factory
      role: screen.factory
    applies_when: the module binds ScreenAssemblyShape to per-screen-factory. Otherwise DISABLED.
    rationale: module boundaries
    check: Under per-screen-factory each screen ships its own seam; under shared-factory the seam is one per module.

  - id: STRUCT-11
    title: UI state lives where the module decided it lives
    severity: important
    enforcement: lint
    predicate: file_required_in_dir
    params:
      slot: UIStateHolder
      slot_value: separate-state-type
      role: screen.state
      trigger_role: screen.viewmodel
      trigger_pattern: '(FormField|\.Section\b|var\s+(is|selected|shows|expanded)\w*(Presented|Expanded|Sheet|Index|Visible|Selected|Shown)\b)'
    rationale: testability
    check: >
      A screen with bound form objects or visual state, in a module that separated that state out,
      carries the state type. A screen with neither is not missing anything.

  - id: STRUCT-12
    title: The value type an extracted view renders holds values, not behaviour
    severity: blocking
    enforcement: lint
    predicate: forbidden_pattern
    params:
      slot: SubviewShape
      slot_value: folder-per-subview
      subject_role: subview.configuration
      pattern: '^\s+(let|var)\s+\w+\s*:\s*(@escaping\s+)?\('
    rationale: testability
    check: >
      A closure stored in the value type makes it non-comparable and drags the caller's lifetime
      into it. Actions reach the view as its own parameters.

  - id: STRUCT-13
    title: An extracted view takes values, never the screen's view model
    severity: blocking
    enforcement: lint
    predicate: forbidden_pattern
    params:
      slot: SubviewShape
      subject_role: subview.view
      pattern: '^\s+(let|var)\s+\w+\s*:\s*(any\s+)?\w*ViewModel\b'
    rationale: testability
    check: >
      A view holding the view model can reach anything, so nothing about it can be asserted from
      its inputs, and it cannot be previewed without constructing the whole screen.

  - id: STRUCT-14
    title: Fixture data lives with the fixtures, not inside the thing that serves it
    severity: suggestion
    enforcement: judgement
    predicate: none
    params: {}
    rationale: readability
    check: >
      A scripted implementation that also carries its payload literals mixes which scenario to
      answer with the content of the answer. The tool surfaces the size ratio; the split is a reading.

  - id: NAME-07
    title: A positional index carries the same label everywhere
    severity: suggestion
    enforcement: lint
    predicate: naming_pattern
    params:
      glob: '**/*.swift'
      declaration: '\((for|_)\s+(?:index|\w+Index)\s*:\s*Int'
      accept_from_vocabulary: IndexLabel
    rationale: readability
    check: One spelling for "at this position" across the module; two make the call sites read as two concepts.

  # ── UNIT  -  the settled screen dialect ────────────────────────────────────
  - id: UNIT-01
    title: A new screen's view model is the unit type, never the deprecated scene base
    severity: blocking
    enforcement: scan
    mechanism: >
      grep the diff's ADDED files for a subclass of the deprecated pre-unit scene base; grep the
      declaration of the unit generic (ViewModel<) for the four type parameters.
    rationale: flexibility
    check: >
      The unit dialect  -  ViewModel<State, ViewAction, Action, Output> with explicit conformer
      typealiases  -  is the only legal shape for new screen code. The pre-unit base class is
      deprecated at its declaration; existing screens convert on touch, and that migration is its
      own work. The finding is a NEW file building on the deprecated base, or a unit view model
      missing its typealias block (the all-compiler-version spelling).

  - id: UNIT-02
    title: Unit state is written only inside the reducer
    severity: important
    enforcement: scan
    mechanism: 'custom regex: assignments to the unit state outside next(_:on:) in ViewModel files'
    rationale: readability
    check: >
      The unit's state moves in one place  -  the reducer  -  so a reader replays a screen's
      behaviour from a single function. A state write from an action handler, a task body or a
      view callback bypasses that replay and is the finding, wherever it compiles.

  - id: UNIT-03
    title: A view model's dependencies are injected properties; init takes only input and output
    severity: important
    enforcement: lint
    mechanism: 'custom regex: a unit view model init with a parameter beyond input:/output:, or a dependency resolved inline at a call site'
    rationale: flexibility
    check: >
      A unit view model is constructible from its Input and its output sink ALONE  -  init(input:output:),
      collapsing to init(output:) when Input is Void. Every OTHER dependency is an @ObservationIgnored
      property resolved by injection (a keyed inject property wrapper, or the project's equivalent), never an
      init parameter and never resolved inline at the use site. Tests substitute a double through a
      task-local overlay or a preview registration  -  there is no init parameter to thread it through  -  and
      observation never tracks a service handle. The finding is an init carrying a dependency parameter
      (defaulted or not), or a dependency resolved at its call site. Scenes, coordinators and outside-contract
      answerers keep their resolving default arms; VIEW MODELS do not.

  - id: UNIT-04
    title: Two typed send doors, never one untyped funnel
    severity: important
    enforcement: scan
    mechanism: 'custom regex: a public/internal send or handle on a view model taking a single action type that both the view and internal callers reach'
    rationale: flexibility
    check: >
      What the VIEW may send (the view-action type) and what the unit sends ITSELF (its own action type)
      are separate types, carried under one action envelope (a two-case `.view` / `.viewModel` enum) that the
      reducer switches on. A view physically cannot construct or send an internal action. The finding is one
      untyped funnel  -  a single action enum, or a `send`/`handle` entry point, that both the view and the
      machine's own effects/children reach  -  which lets a view invoke internal machinery. The outbound
      output stays a third, separate per-screen vocabulary.

  - id: UNIT-05
    title: An effect returns the unit's own action and never touches state; loading is derived
    severity: important
    enforcement: scan
    mechanism: 'custom regex: a state write or a loading Bool/counter set inside an effect/task body in a view model'
    rationale: readability
    check: >
      Async work is registered by id and its closure RETURNS the unit's own action, which re-enters through
      the reducer  -  the closure cannot read or write state. Loading is DERIVED from the running-effects
      registry (the set of in-flight ids), never a hand-set Bool or a hand-balanced count that can drift from
      the truth; dismissing loading cancels the work rather than orphaning it, and a same-id re-run supersedes
      the one in flight. The finding is an effect body that assigns state, or a `isLoading`/loading counter set
      by hand instead of derived.

  - id: UNIT-06
    title: A view holds the erased face, not the concrete unit
    severity: important
    enforcement: scan
    mechanism: 'custom regex: a SwiftUI view storing a concrete view model type instead of the erased face, or calling emit/run/an internal action from view code'
    rationale: flexibility
    check: >
      A view stores the ERASED face  -  a surface exposing only the projected view state, the view-action
      door, and bindings  -  not the concrete unit type. The concrete type's emit, effect-run, and own action
      are not spellable from view code; the fence is the TYPE, not a naming convention. The finding is a view
      whose stored model is the concrete unit (so it can reach `emit`/`run`/internal actions), or view code
      that calls one of those.

  - id: UNIT-07
    title: Chrome on the unit path is one value-typed notice, not per-screen slots
    severity: important
    enforcement: scan
    mechanism: 'custom regex: a unit view model declaring its own alert/toast/modal slot properties instead of raising the shared value-typed notice'
    rationale: readability
    check: >
      On the unit system a failure or a prompt is one VALUE-typed notice (equatable, codable, closure-free  -
      responses travel back as values through a respond entry point, never a callback stored beside the
      chrome) that the base escalates up the host chain to whoever renders it. A domain screen does not raise
      chrome by hand or hold its own alert/toast/modal slot trio (that is the pre-unit shape). The finding is a
      unit view model carrying per-screen chrome slots or passing a closure through the notice.

  - id: UNIT-08
    title: View-facing state is projected from machine state, not the same type
    severity: important
    enforcement: lint
    mechanism: 'custom regex: a view reading the durable machine-state type directly instead of the projected view-state'
    rationale: readability
    check: >
      The durable machine state (every field the reducer needs, including bookkeeping the screen never draws)
      and the view-facing state the view renders are distinct: the view state is a PROJECTION produced by the
      view model (a typealias to a single machine-state field when that is all the view needs, an earned struct
      at two). A view reads the view state only, so adding an internal field to the machine state never changes
      what the view can see or spell. The finding is a view bound directly to the durable machine-state type,
      or a machine-state field with no reason to exist beyond what the view already renders.

  - id: SAFE-03
    title: A field reset that must erase the value writes the value before clear()
    severity: important
    enforcement: scan
    mechanism: 'custom regex: .clear() on a form field with no value write in the surrounding statement group'
    rationale: flexibility
    check: >
      The shared form field's clear() resets STATE only  -  the value survives it. Every reset
      path that must also erase what was typed (switching an entry method, finishing a secure
      flow, leaving a screen that held credentials) writes value = "" (a picker: deselect())
      first, then clear(), in that order  -  the value write may fire a validation policy and the
      trailing clear() leaves the field pristine. A bare clear() on such a path has shipped
      stale identifiers and surviving passwords before; treat it as the finding unless the reset
      demonstrably must keep the value.

  - id: MOD-08
    title: Screens bind the shared formatters and rule facades; a hand-rolled one is a finding
    severity: important
    enforcement: scan
    mechanism: 'custom regex: DateFormatter(/NumberFormatter( construction and digit/dial-code string surgery inside Screens/**'
    rationale: flexibility
    check: >
      Dates, money, grouped amounts, dial-code spellings and validation bounds each have one
      shared home  -  the core formatter families and the module's form-rules facade. A screen
      that constructs its own formatter or re-derives a bound duplicates a wire contract that
      already has an owner, and the copies drift apart silently. The finding is the construction
      site; the fix is binding, not re-deriving.
