version: 0.3.0
updated: 2026-08-18
owner: iOS platform
description: >
  The ios-module-structure rule registry. Where a declaration lives, what its file is called, and what
  its folder must contain beside it. Sibling to the coding-standard registry, which governs file
  CONTENT; this one governs the TREE. IDs are stable and never renumbered - a rule is retired by
  status, not deletion.

  MATURITY: 0.x. The rule set was generalised from one module's conventions and stress-tested
  against one more; that second contact added two dialect slots. Expect a third module to add
  more. Slots and carve-outs absorb that without breaking a binding, but do not treat this as a
  frozen standard yet.

  Every rule here is written over ROLES, never over literal paths or type names. A role is bound by
  the module's own overlay. That is what lets one registry serve a module that spells its subview
  folder one way and a module that spells it another, without either becoming a hundred findings.

# What these rules may be applied to. A consumer resolves this BEFORE selecting rules and records
# what it dropped. Per-rule `scope:` narrows this and never widens it.
scope:
  languages: [swift]
  # The engine reads this: it holds no language of its own.
  sourceExtension: .swift
  paths:
    - "**/*.swift"
  excludePaths:
    - "**/*.generated.swift"
    - "**/Generated/**"
    - "**/*.pb.swift"
    - "**/.build/**"
    - "**/DerivedData/**"
  notCovered:
    objective-c: "*.m / *.mm / *.h - no rules here speak for them; report as a coverage gap"
    resources: "asset catalogs, plists, storyboards - the tree rules do not describe them"

severity_levels: [blocking, important, suggestion]
enforcement_kinds:
  lint: a tool decides it mechanically from the tree and file text
  scan: a tool measures it across the module (counts, ratios, mirrors)
  judgement: requires a human; the tool can only surface the candidate
exception_marker: "// standard:exception(<RULE-ID>) <reason> <expiry:YYYY-MM-DD>"

# ---------------------------------------------------------------------------
# Roles
# ---------------------------------------------------------------------------
# A rule names a role; the overlay binds the role to this module's reality. An UNBOUND role
# disables every rule that reads it, and the audit reports that rather than guessing a shape.
#
# Binding forms:
#   glob     - a path pattern relative to the module root, e.g. "Sources/*/Screens/*/Presentation/*Scene.swift"
#   derived  - a pattern computed from another role's match, e.g. "{dir}/{stem}Configuration.swift"
# `{dir}`, `{stem}`, `{screen}` and `{target}` are the only substitutions.
roles:
  screen.root:
    description: the directory that is one screen. Everything else is resolved relative to it.
    required: true
  screen.entry:
    description: the file a coordinator or factory constructs to show the screen.
  screen.viewmodel:
    description: the type holding the screen's behaviour and service calls.
  screen.state:
    description: the type holding the screen's UI state, when the module separates it from the view model.
  screen.viewstate:
    description: the unit dialect's view-facing state value, one declaration per file.
  screen.viewaction:
    description: the unit dialect's view-action vocabulary, one declaration per file.
  screen.viewmodelaction:
    description: the unit dialect's own-action vocabulary, one declaration per file.
  screen.viewmodelstate:
    description: the unit dialect's reducer-owned state, one declaration per file.
  target.configurator:
    description: >
      a screen-bearing target's dependency configurator - the registration + validation home a
      multi-target package carries once per target.
  screen.analytics:
    description: the screen's analytics surface.
  screen.factory:
    description: the seam another module calls to build the screen.
  contracts.output:
    description: >
      the screen's Output enum in the module's flow-contracts target - every navigation exit,
      nothing else. Lives OUTSIDE the module tree; the overlay's contracts.root names where.
  contracts.providing:
    description: >
      the screen's vending seam (ScreenProviding protocol) in the flow-contracts target,
      beside its Output. Lives OUTSIDE the module tree; the overlay's contracts.root names where.
  screen.mapper:
    description: the wire-to-domain translation for the screen.
  service.dir:
    description: one directory per service operation, holding that operation's models.
  service.request:
    description: the request half of a service operation.
  service.response:
    description: the response half of a service operation.
  subview.dir:
    description: where a screen's extracted views live, when the module extracts them into folders.
  subview.view:
    description: an extracted view belonging to one screen.
  subview.configuration:
    description: the value type an extracted view renders.
  repository.live:
    description: the production implementation of a screen's data access.
  repository.mock:
    description: the offline/scripted implementation used by previews and the debug menu.
  shared.root:
    description: the module's cross-screen folder.
  source.root:
    description: the module's source tree, as the mirror rule's counterpart to the test tree.
  test.root:
    description: the module's test tree.

# ---------------------------------------------------------------------------
# Dialect slots
# ---------------------------------------------------------------------------
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 structure pass. Those rules bind to a slot here instead. An
    UNBOUND slot disables its rules and the audit says so, rather than defaulting to one dialect
    silently.

    A slot is only legitimate when both values are genuinely defensible. A module with no analytics
    surface has not chosen a different dialect, it is missing the surface - so STRUCT-04 is a rule,
    not a slot.
  slots:
    - id: ScreenLayerShape
      governs: [STRUCT-01, STRUCT-02]
      values:
        layered: >
          every screen carries the same fixed set of layer folders, including the ones that are
          empty for that screen. The tree teaches the architecture; a missing folder is a finding.
        organic: >
          a screen carries only the layers it actually has. Nothing is created to satisfy a shape,
          and a missing layer means the screen genuinely has no work at that level.
      note: >
        Module-wide. The audit reads it to decide whether "screen has no data layer" is a finding
        or a fact.
    - id: ServiceModelDir
      governs: [STRUCT-03]
      values:
        under-data: the request/response pair sits in the data layer, beside the code that calls the service.
        under-mapper: the pair sits beside the mapper that translates it.
      note: The pairing rule is the same either way; only the parent differs.
    - id: ScreenAssemblyShape
      governs: [STRUCT-05]
      values:
        per-screen-factory: each screen ships its own construction seam.
        shared-factory: one factory per module builds every screen - pre-conversion residue;
          a converted module binds per-screen-factory, and a NEW module may not choose this.
      note: >
        Both put construction behind a seam, which is the property STRUCT-05 protects. Counting
        factories against screens tells you which one the module chose.
    - id: UIStateHolder
      governs: [STRUCT-06]
      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 - pre-conversion residue for
          form screens; the settled shape is the separate state type.
      note: >
        Unbound, STRUCT-06 is disabled - "the view model holds a form field" is only a finding in a
        module that decided it should not.
    - id: SubviewShape
      governs: [STRUCT-07, STRUCT-08, STRUCT-09]
      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.
      note: >
        The sibling rules only make sense under folder-per-subview. Under flat they are disabled,
        not violated.
    - id: ScreenCompositionShape
      governs: [STRUCT-10]
      values:
        extracted: >
          a fragment of the screen becomes a named view beside it. The entry file is a table of
          contents; each piece can be previewed and snapshotted alone.
        in-file: >
          the entry file composes its own fragments. One file tells the whole screen's story and a
          reader never chases a name across the folder.
      note: >
        Both keep the body readable, which is what STRUCT-10 protects. Counting view members on
        entry files against extracted view files tells you which one the module chose.
    - id: ServiceModelPairing
      governs: [STRUCT-03]
      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; a bodyless GET has a response
          and nothing else.
      note: >
        Under response-only a lone response is normal and only a lone REQUEST is the finding.

    - id: CopyResolution
      governs: [VOCAB-06]
      values:
        render-site: copy keys are resolved where the text is rendered.
        copy-layer: a per-screen type owns every string the screen shows.
      note: Both keep copy findable; mixing them is what costs.

    - id: UnitDialect
      governs: [STRUCT-19, STRUCT-20]
      question: which view-model generation do this module's screens ride?
      values:
        adopted: screens ride the unit view model (the State/ViewAction/Action/Output generic)
          with the one-declaration-per-file vocabulary under the presentation models folder.
        pre-unit: screens still ride the deprecated pre-unit scene base. Legitimate only as a
          recorded migration state - the run reports it as debt, and a NEW module may not
          choose it.

# ---------------------------------------------------------------------------
# Rules
# ---------------------------------------------------------------------------
rules:
  # --- Tree ---------------------------------------------------------------
  - id: STRUCT-01
    title: A screen carries the layer folders its module's shape declares
    severity: important
    enforcement: lint
    predicate: dir_required_in_dir
    params:
      slot: ScreenLayerShape
      slot_value: layered
      vocabulary_key: LayerDirs
    applies_when: the module binds ScreenLayerShape. Unbound, this rule is DISABLED.
    applies_to_value: layered
    rationale: navigability
    check: >
      Under `layered`, a screen missing one of the declared layer folders is a finding: the tree is
      the architecture diagram and a hole in it makes the reader guess. Under `organic` this rule
      does not run at all.

  - id: STRUCT-02
    title: A screen's presentation layer is never absent
    severity: blocking
    enforcement: lint
    predicate: dir_required_in_dir
    params:
      vocabulary_key: PresentationDir
    rationale: navigability
    check: >
      Whatever else a screen does or does not carry, it renders something. A screen directory with
      no presentation layer is either dead or misfiled; both are findings.

  - id: STRUCT-03
    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
    applies_when: the module binds ServiceModelDir. Unbound, this rule is DISABLED.
    rationale: navigability
    check: >
      A directory that holds one half of a service operation must hold the other. A lone response
      means the request is inlined somewhere a reader will not find it, and a lone request means
      the response is being decoded into a type that does not say which call produced it.
    exempt: [operations whose request carries no fields and is not modelled at all]

  - id: STRUCT-04
    title: A screen owns an analytics surface
    severity: important
    enforcement: lint
    predicate: file_required_in_dir
    params:
      role: screen.analytics
      screen_exemptable: true
    rationale: observability
    check: >
      A screen a user can reach reports that they reached it. The surface is one type per screen so
      that its events can be spied in a test without touching the tracker.
    exempt: >
      Screens with no product identity of their own - a picker sheet, a system-wrapper cover, a web
      host - presented inside another screen's flow. The presenting screen tracks the interaction
      that opened them. Inventing screen-view events for them puts names in the analytics schema
      nobody asked for. List them in the overlay's `exempt_screens`.

  - id: STRUCT-05
    title: A screen ships the construction seam its module's assembly shape declares
    severity: important
    enforcement: scan
    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 construction seam. Under shared-factory
      the seam is one per module and this rule does not run per screen.

  - id: STRUCT-06
    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)'
    applies_when: >
      the module binds UIStateHolder to separate-state-type AND the screen actually has UI state
      (a bound form field, a selected index, a presented sheet). Otherwise DISABLED.
    rationale: testability
    check: >
      A screen with bound form objects or visual state, in a module that separated that state out,
      must carry the state type. A screen with neither is not missing anything.

  - id: STRUCT-07
    title: An extracted view carries the value type it renders
    severity: important
    enforcement: lint
    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
    applies_when: the module binds SubviewShape to folder-per-subview. Otherwise DISABLED.
    rationale: testability
    check: >
      Every extracted view has a value type beside it holding what it renders. A view that reads
      its data from anywhere else cannot be rendered in isolation, which means it cannot be
      previewed or snapshotted.

  - id: STRUCT-08
    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+)?\('
    applies_when: the module binds SubviewShape to folder-per-subview. Otherwise DISABLED.
    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; the value type carries only what is
      drawn.

  - id: STRUCT-09
    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'
    applies_when: the module binds SubviewShape. Otherwise DISABLED.
    rationale: testability
    check: >
      A view holding the view model can reach anything, so nothing about it can be asserted from
      its inputs. It also cannot be previewed without constructing the whole screen.

  - id: STRUCT-10
    title: The screen's entry file composes; it does not also carry the pieces
    severity: important
    applies_when: the module binds ScreenCompositionShape to extracted. Otherwise DISABLED.
    enforcement: lint
    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]
    rationale: readability
    check: >
      The entry file holds its properties, its init, one body and its previews. A second view
      member on it is a fragment that either belongs in the body's composition or belongs beside
      the screen as an extracted view. Moving those members into a view-composition extension
      file (a "+ViewBuilder" / "+Subviews" companion) is the same finding relocated, not a fix -
      that pattern is retired; the extracted subview pair is its replacement. The exception is a
      fragment the type system pins to the call site - an alert's content, a modifier-constrained
      builder - which cannot be moved.

  - id: STRUCT-11
    title: A cross-screen folder holds no type named after one screen
    severity: important
    enforcement: lint
    predicate: prefix_collision
    params:
      subject_role: shared.root
    rationale: module boundaries
    check: >
      A type in the shared folder carrying a screen's prefix is shared by accident. Either it is
      genuinely common, and the prefix is wrong, or it belongs to that screen and the folder is
      wrong.

  - id: STRUCT-12
    title: The test tree mirrors the source tree
    severity: suggestion
    enforcement: scan
    predicate: mirror_required
    params:
      test_role: test.root
      source_role: source.root
    rationale: navigability
    check: >
      A reader looking for a type's tests should find them at the same path under the test root. A
      test folder with no source counterpart is either testing something that moved or grouping by
      a concept the source does not have.

  # --- Size and shape ------------------------------------------------------
  - id: STRUCT-13
    title: File size has a target and a ceiling, and tests get a wider one
    severity: important
    enforcement: lint
    predicate: file_size
    params:
      test_marker: /Tests/
    rationale: readability
    metric: lines per file
    check: >
      Past the target a file is a candidate for splitting; past the ceiling it is a finding. Tests
      get a wider band because a suite legitimately repeats its arrange step. The numbers live in
      the overlay - a module that has not chosen them gets the registry defaults.
    exempt: [generated sources, fixture and mock data files]

  - id: STRUCT-14
    title: Layout numbers are not a type
    severity: suggestion
    enforcement: lint
    predicate: forbidden_pattern
    params:
      glob_from_vocabulary: PresentationDir
      pattern: '^\s*(?:private\s+)?(?:struct|enum)\s+\w+(Constants|Layout|Metrics|Dimensions)\b'
    rationale: readability
    check: >
      A per-screen constants, layout or metrics type collects numbers that belong either to a
      design-token namespace or to the one view that uses them. It becomes a dumping ground whose
      entries no one dares delete.

  - id: STRUCT-15
    title: A view type is previewable, and previews it
    severity: suggestion
    enforcement: lint
    predicate: required_pattern
    params:
      glob: '**/*.swift'
      when_pattern: '^\s*(?:public\s+)?struct\s+\w+(View|Scene)\s*:'
      pattern: '#Preview'
      label: no preview
    rationale: testability
    check: >
      A view with no preview is a view nobody looked at in isolation. The preview is also the
      cheapest proof that the type can be constructed from values alone.

  # --- Vocabulary ----------------------------------------------------------
  - id: VOCAB-01
    title: A boolean reads as a claim about the subject
    severity: important
    enforcement: lint
    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
    rationale: readability
    check: >
      A boolean is prefixed so the reader knows it is one and knows which way it points. Plural
      subjects agree with the plural rather than taking a singular prefix. A bare adjective or noun
      forces the reader to open the declaration.

  - id: VOCAB-02
    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 spellings make the call sites read
      as two different concepts.

  - id: VOCAB-03
    title: Section headings come from a closed vocabulary
    severity: suggestion
    enforcement: lint
    predicate: vocabulary
    params:
      vocabulary_key: SectionHeadings
      declaration: '^\s*//\s*MARK:\s*-\s*(.+)$'
      exempt_patterns: ['^[a-z][a-zA-Z0-9/{}.\- ]*$', '→', '↔']
    rationale: navigability
    check: >
      Headings are a map, and a map with a hundred distinct labels is not one. The vocabulary lives
      in the overlay. Headings naming an external contract - an endpoint, a protocol being
      conformed to, a wire direction - are outside the vocabulary by construction and are exempt.

  - id: VOCAB-04
    title: A method name says which layer speaks
    severity: important
    enforcement: lint
    predicate: naming_pattern
    params:
      subject_role: screen.viewmodel
      declaration: '^\s+(?:private\s+)?func\s+([a-z]\w*)\s*\('
      reject_from_vocabulary: BareVerbs
    rationale: readability
    check: >
      A user event, a service call and a derived value are three different things and their names
      say which. A bare verb on a presentation type does not say whether it asks the network, mutates
      state, or both.

  - id: VOCAB-05
    title: A type's last word says what it does, and some last words say nothing
    severity: important
    enforcement: lint
    predicate: naming_pattern
    params:
      glob: '**/*.swift'
      declaration: '^(?:public\s+)?(?:final\s+)?(?:struct|class|enum|actor|protocol)\s+(\w+)'
      reject_from_vocabulary: ForbiddenTypeSuffixes
    rationale: readability
    check: >
      The role suffix set lives in the overlay. Suffixes that describe no role - the ones that mean
      "code that does things" - are listed there as forbidden, and a type carrying one has not been
      named yet.

  - id: VOCAB-06
    title: Copy is resolved in one place per module
    severity: suggestion
    enforcement: lint
    predicate: forbidden_pattern
    params:
      slot: CopyResolution
      glob: '**/*.swift'
      pattern_from_vocabulary_by_slot:
        render-site: CopyLayerTypePattern
        copy-layer: RawCopyKeyAtRenderSitePattern
    applies_when: the module binds CopyResolution. Unbound, this rule is DISABLED.
    rationale: navigability
    check: >
      Under `render-site` a per-screen copy layer is the finding; under `copy-layer` a raw key at a
      render site is. Either is fine; both at once means a reader has to check two places.

  - id: VOCAB-07
    title: One declaration per file, and the file is named after it
    severity: important
    enforcement: lint
    predicate: naming_pattern
    params:
      glob: '**/*.swift'
      declaration: '^(?:public\s+)?(?:final\s+)?(?:struct|class|enum|actor|protocol)\s+(\w+)'
      match_file_stem: true
    rationale: navigability
    check: >
      A reader who knows a type's name knows its file. A second top-level declaration in a file is
      findable only by grep - and gets deleted by accident when the first one moves.
    exempt: >
      A private helper that exists only for the file's own declaration, and an extension of the
      file's own type.

  # --- Judgement (surfaced, never auto-failed) -----------------------------
  - id: STRUCT-16
    title: A subfolder is earned by its contents
    severity: suggestion
    enforcement: judgement
    predicate: none
    params: {}
    rationale: navigability
    check: >
      A folder holding one or two files usually names a concept the module does not actually have.
      The tool can list the candidates; whether a given one is a seam worth keeping is a reading,
      not a count. Service operation folders are exempt by construction - the pair is the point.

  - id: STRUCT-17
    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 own payload literals mixes the decision of
      which scenario to answer with the content of the answer. The tool can surface the size ratio;
      the split itself is a judgement.

  - id: STRUCT-18
    title: A converted screen ships its contract pair in the flow-contracts target
    severity: important
    enforcement: scan
    predicate: contract_pair_required
    params:
      output_role: contracts.output
      providing_role: contracts.providing
    applies_when: >
      the overlay binds contracts.root (the path of the module's flow-contracts target, relative
      to the module root) AND both contracts.output and contracts.providing. Unbound, DISABLED -
      a pre-conversion module has no contract side, and the run reports the lost coverage instead
      of guessing one.
    rationale: module boundaries
    check: >
      A converted flow's screen is reachable only through its contract seam: an Output enum naming
      every navigation exit and a providing protocol vending the built screen. Both live in the
      flow-contracts target, not in the module, so the module tree alone cannot prove a screen is
      actually wired. Each screen directory must have a matching directory under contracts.root
      holding both files; a screen without one is invisible to every consumer of the seam.

  - id: STRUCT-19
    title: A unit screen carries the four-file vocabulary, one declaration per file
    severity: important
    enforcement: lint
    predicate: file_required_in_dir
    params:
      slot: UnitDialect
      slot_value: adopted
      role: screen.viewmodelstate
      companion_roles: [screen.viewstate, screen.viewaction, screen.viewmodelaction]
    applies_when: >
      the module binds UnitDialect to adopted AND the four unit vocabulary roles. Unbound or
      pre-unit, DISABLED - and the pre-unit binding itself is reported as recorded debt.
    rationale: readability
    check: >
      The unit dialect spells a screen's contract as four value files beside the presentation
      models - view state, view action, own action, reducer state - each a single declaration in
      a file of its own name. A vocabulary member folded into the view model file or sharing a
      file with a sibling hides the screen's surface from the reader who navigates by name.

  - id: STRUCT-20
    title: An adopted module's view models ride the unit generic, never the deprecated base
    severity: blocking
    enforcement: scan
    predicate: forbidden_pattern
    params:
      slot: UnitDialect
      slot_value: adopted
      subject_role: screen.viewmodel
      pattern: ':\s*BaseViewModel\b'
    applies_when: >
      the module binds UnitDialect to adopted. In a pre-unit module this rule is DISABLED - the
      migration converts screens on touch and drowning it in findings would hide the ones that
      matter - but the binding is reported as debt every run.
    rationale: flexibility
    check: >
      The pre-unit scene base is deprecated at its declaration. In a module that declared itself
      adopted, any view model still subclassing it is a regression, not a leftover - the overlay
      said this module is done.

  - id: STRUCT-21
    title: Every screen-bearing target carries its dependency configurator
    severity: important
    enforcement: lint
    predicate: target_configurator_required
    params:
      role: target.configurator
    applies_when: >
      the overlay binds target.configurator. Unbound, DISABLED and reported - a single-target
      module may register at its screens instead, and that is a dialect, not a defect.
    rationale: module boundaries
    check: >
      In a multi-target package each target that ships screens owns one registration home: the
      configurator that registers its repositories, use cases and screen seams, validates them,
      and exposes the store entry the composition root aggregates. A screen-bearing target
      without one leaves its seams registered from another target - reachable, but owned by
      nobody the reader can find.
