os-sdlc cop Sdlc/Structural/DomainEffectInEdge: an edge turns what it read into a verdict, a selection of work, or a next step (A19 R3) #1511

Closed
opened 2026-11-02 21:49:07 +00:00 by jared · 3 comments
Owner

Problem

An edge object (an adapter or an entry) turns what it read into a domain outcome: a verdict (refuse, fail, report a problem), a selection of work (ready, unseen, open items), or a next step of our process. The outcome is our rule. Because it is inside the adapter, its test needs the resource. An edge that only reports what the resource holds, or that the resource failed, is translation.

Evidence: docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md (rule R3, section 7, cop E) and docs/system-design-format/proof/2026-11-02-detector-a19/facts/rule-v3-notes.md (clauses E1 to E4). On 147 labeled sites, the v3 definition of E fires on 12 TRUE sites and 0 FALSE sites.

No cop finds this shape. Sdlc/Structural/EffectInModel is disabled in this repo because no LedgerPath is set (#1507).

This cop is the third of three. It needs the shared objects from the P ticket (Sdlc/Structural/PrecedenceInEdge: EdgeScope, EffectReach). Build P first.

Reproduce

n/a: new cop, nothing to reproduce

Approach

Add RuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < RecordingCop.

Site role (same text in the P, O, and E tickets). The cop reports only in an edge, and it gets the role from the code, not from a ledger:

  • adapter: the nearest enclosing class or module body holds at least one call that EffectInModel::EffectCall#label names. For code outside all classes, use the file's top level. The cop gives EffectCall its own EffectConstants default: File Dir IO FileUtils YAML JSON Open3 Process Kernel. This is the EffectCall default plus FileUtils.
  • entry: EntryPoint.file?(path) is true. That is, the first line starts with #!, or the file is in a folder named bin.
  • In an entry, if a test's values come only from the enclosing method's parameters, ARGV, or literals, and its skipped path calls no effect, the test is input, not a decision (A19 NOT).

Required widening. F120's class reads its files only through Prism.parse_file. Today EffectCall treats only YAML and JSON *_file methods as file reads, and these two constants are hard-coded. Add a FileFormConstants key to EffectCall, with the default YAML JSON, so that EffectInModel does not change. EdgeScope passes YAML JSON Prism. This widens the role scope for P and O too, so run the P and O whole-tree checks again (see the AC).

No ledger. The cop does not read LayerLedger. Existing behavior: EffectInModel reports nothing when LedgerPath is not set (#1507). When LedgerPath names a missing file, LayerLedger::Ledger#role_by_const raises RuboCop::Error. The ledger cannot give roles for this cop, because LayerMap.cpath_by_path maps only files under os-sdlc's own lib/ through OsSdlc.loader. It cannot map os-me, os-backlog, bin/ scripts, or a foreign project. Because the role comes from the code, the cop has no "not configured" state. Thus it never does nothing silently.

ADR constraints.

  • ADR-0298: register the cop only in plugins/os-sdlc/.rubocop.yml. os-sdlc layers this config at run time and installs nothing into target projects.
  • ADR-0082: Sdlc cops apply to every project through the layered config. The cop must give correct results on foreign codebases with no ledger and no per-project setup.
  • ADR-0090: Zeitwerk. cops/structural/domain_effect_in_edge.rb holds Sdlc::Structural::DomainEffectInEdge.

Terms.

  • Ours (a condition computed in this file) is one of these:
    • (i) a call to a same-file predicate method;
    • (ii) a presence test (truthiness, nil?, empty?, any?, none?) on a local that the method assigns directly from a same-file method call, on that same-file method, on an ivar, or on a message with no arguments to an ivar. The value must reach no effect call through same-file methods (EffectReach);
    • (iii) a comparison over an arithmetic or aggregate result written in this method (%, tally, count, size, max).
  • Caller input is one of these:
    • (1) a bare method parameter, or a message to one. Literal-key slot reads (row.fetch("job"), or row.fetch(c) where c iterates a literal list) are fields of a parsed row, not caller input;
    • (2) a value whose receiver chain, through locals and block elements of this method, ends at an ivar set from an initialize parameter.
  • Verdict effect is one of these:
    • (a) it raises, directly or through a same-file method whose body raises (raise(X.new(...)) included);
    • (b) it returns a string literal (plain or interpolated) under the condition as the method's value;
    • (c) it maps the selected items into strings or tagged rows (an array or hash that holds a string or symbol literal tag);
    • (d) it returns a constant assigned in this file under a modifier if.

Value rule (R3, effect). The cop reports a site when one of these clauses holds:

  • E1 (verdict choice). A ternary or a || b. Each candidate is a same-file method call or a plain (not interpolated) string or symbol literal. For a || b, each candidate is a same-file method call. The condition is ours. E1 does not apply the caller-input test, because v3 E1 allows ivars.

  • E2 (our computation gives a verdict). The condition is ours, it is not caller input, and the branch has a verdict effect.

  • E3 (a reader's return selects or starts a step). Either form:

    • (i) A select, reject, or find over a same-file collection. Its block tests the presence (any?, empty?, none?, nil?) of an argument-free message to the block element.
    • (ii) Inside an iteration, a presence test on a local assigned from a message to another object, where the taken branch runs two or more statements and ends with next or return.

    In both forms, the test is not caller input.

  • E4 (a parsed field gives a verdict). A presence test on a literal-key slot read that is not caller input, with verdict effect (c) only.

The terms ours, caller input, and verdict effect are new facts. No existing helper computes them. They live in the objects in the owner sketch. The cop records clause: (e1 to e4) and role: through RecordingCop#report.

Shared helpers (after #1509 and #1512).

  • EdgeScope#role(node, values) gives :entry only to code outside every class and module body (#1512). A class inside an entry file takes the adapter role when its body holds an effect call, otherwise no edge role.
  • EdgeScope#input? tests only that the values come from parameters, ARGV, or literals. The cop tests the second entry condition (the skipped path calls no effect) itself, with EffectReach#labels.
  • The cop passes the tested values as values to EdgeScope#role, not the whole node. PASS-44 depends on this.
  • Use QueryMethodReachesWrite::SameFileDefs, as precedence_in_edge.rb does. Use PrecedenceInEdge::EFFECT_CONSTANTS; do not copy the list.
  • PrecedenceInEdge builds one effect_config hash for both EffectReach and EdgeScope (precedence_in_edge.rb:15-19). Add FileFormConstants only to the config that EdgeScope gives to EffectCall. EffectReach keeps the YAML JSON default, so only the role scope widens.

Required work:

  1. Add the cop class RuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < RecordingCop — check: present class DomainEffectInEdge in plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rb
  2. Add DomainEffectInEdge::Condition — check: present class Condition in plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/condition.rb
  3. Add DomainEffectInEdge::CallerInput — check: present class CallerInput in plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/caller_input.rb
  4. Add DomainEffectInEdge::Effect — check: present class Effect in plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/effect.rb
  5. Make EffectInModel::EffectCall read FileFormConstants from cop_config, with the default YAML JSON — check: present FileFormConstants in plugins/os-sdlc/lib/os_sdlc/cops/structural/effect_in_model/effect_call.rb
  6. Make EdgeScope pass YAML JSON Prism as FileFormConstants — check: present Prism in plugins/os-sdlc/lib/os_sdlc/cops/structural/edge_scope.rb
  7. Register the cop with no Exclude key — check: present Sdlc/Structural/DomainEffectInEdge: in plugins/os-sdlc/.rubocop.yml
  8. Add the cop test with the must-fire and must-pass fixtures below — check: present DomainEffectInEdge in plugins/os-sdlc/tests/cops/domain_effect_in_edge_test.rb
  9. Add the lesson YAML with the text in the Lesson subsection — check: present key: Sdlc/Structural/DomainEffectInEdge in plugins/os-sdlc/lint-lessons/sdlc-structural-domain-effect-in-edge.yml
  10. Reuse the shared effect constant list — check: present PrecedenceInEdge::EFFECT_CONSTANTS in plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rb
  11. Add FileFormConstants only inside EdgeScope, not to the shared effect_config hash — check: absent FileFormConstants in plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rb

Orchestrator work: Whole-tree run: run the cop alone with the os-sdlc config over every Ruby file under plugins/, including bin/ scripts and the os-me and os-backlog plugins; put a table in the PR with one row for each hit (site, clause, TRUE or FALSE, and the reason by the swap test); the run must have zero false hits; for each false hit, either make the value rule tighter and add the hit as a must-pass fixture, or stop and file a ticket with /os-backlog:capture; also list each must-fire site that the real-tree run does not report, with the fact that blocks it. After the FileFormConstants widening, run Sdlc/Structural/PrecedenceInEdge and Sdlc/Structural/DomainOperandInEdge over plugins/ again; neither run may have a new false hit.

Lesson

The shared lesson core comes from the P ticket (Sdlc/Structural/PrecedenceInEdge). The why and correction values and the opening of decision below are copied word for word from it.

key: Sdlc/Structural/DomainEffectInEdge
signal: >-
  An edge turns what it read into a verdict (refuse, fail, a problem), a selection of work (ready,
  unseen, open), or a next step, by a condition that it computes itself.
sign_of: >-
  A domain outcome inside the object that reads the resource: the edge decides what the data means
  for our process, not only what the data is.
why: >-
  Lens A19: a domain rule inside an edge needs the resource to run, so its test cannot use literal
  inputs. Lens principle 9: "Code that decides takes values and returns values." Source: Gary
  Bernhardt, "Functional Core, Imperative Shell" (2012). An edit that inverts the rule then fails no test.
decision: >-
  Replace the resource with another one that holds the same facts. If the rule survives, it is a
  domain rule. Here, ask: would a different resource with the same facts give the same verdict,
  selection, or step? If yes, the outcome is ours. If you find no smell at this site (the branch
  only reports what the resource holds or that it failed), file a false-positive ticket with the
  site and one must-fire fixture from this cop's tests. Do not change the code to silence the cop.
correction: >-
  The adapter returns the values it read, in the resource's terms. A decision object, or the value
  that owns the data, picks. Test the rule with literal inputs.
example:
  before: |-
    class InvoiceFile
      def problems
        rows.select { |row| row.fetch("paid_at").empty? }.map { |row| "#{row.fetch("id")}: unpaid" }
      end

      private

      def rows = File.readlines(@path, chomp: true).map { |line| %w[id paid_at].zip(line.split(",", -1)).to_h }
    end
  after: |-
    class InvoiceFile
      def rows = File.readlines(@path, chomp: true).map { |line| %w[id paid_at].zip(line.split(",", -1)).to_h }
    end

    Unpaid = Data.define(:rows) do
      def problems = rows.select { |row| row.fetch("paid_at").empty? }.map { |row| "#{row.fetch("id")}: unpaid" }
    end

Acceptance criteria

  1. Given the repository after the change, when eager load runs, then Sdlc/Structural/DomainEffectInEdge is registered from plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rb as RuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < RecordingCop and loading passes (ADR-0090).
  2. Given any fixture that makes the cop report, when it reports, then the offense message is exactly An edge turns what it read into a verdict, a selection of work, or a next step. Return the values it read; a decision object or the value that owns the data decides. and the test asserts it verbatim.
  3. Given plugins/os-sdlc/.rubocop.yml, when it is read, then it has Sdlc/Structural/DomainEffectInEdge: Enabled: true with no Exclude key (ADR-0298).
  4. Given plugins/os-sdlc/tests/cops/domain_effect_in_edge_test.rb, when it runs, then every fixture goes through CopHelpers#offenses_for.
  5. Given each of the 12 must-fire fixtures FIRE-1 to FIRE-12 listed below, when the cop runs, then it reports exactly one offense.
  6. Given each of the 44 must-pass fixtures PASS-1 to PASS-44 listed below, when the cop runs, then it reports no offense.
  7. Given a fixture that the cop wrongly reports, when the cop is made tighter, then the change is to the value rule and no Exclude list, disable comment, or site list is added.
  8. Given EffectCall with no FileFormConstants setting, when the EffectInModel tests run, then they pass with no change to them.
  9. Given plugins/os-sdlc/lint-lessons/sdlc-structural-domain-effect-in-edge.yml and sdlc-structural-precedence-in-edge.yml, when the why value, the first two sentences of decision, and the correction value are diffed, then the diff is empty and the PR shows it.
  10. Given the full suite and the os-sdlc rubocop config, when they run on the new files, then the suite is green and the new files pass.

The cop covers the whole R3 shape set above, not only the sample sites. In all fixtures, "adapter class" means that the class body also holds def raw = File.read(@path). "Entry" means that the fixture path is bin/tool.

Must-fire (12). These are all the R3 TRUE sites that the v3 definition catches:

  • FIRE-1: F010 crc/shares/verdict_split.rb:18
    def call
      problems = rows.flat_map { |row| gaps(row) }
      problems.empty? ? pack(rows) : refused(problems)
    end
    def gaps(row) = %w[job concept].select { |c| row.fetch(c).empty? }.map { |c| "empty #{c}" }
    
  • FIRE-2: F026 implementation/mutation_check.rb:17
    def report
      line = precondition_failure
      line ? failure(line) : run
    end
    def precondition_failure = ("no mutation" unless @mutation)
    
  • FIRE-3: F050 implementation/mutation_check.rb:46
    def precondition_failure = mutation_problem || command_problem
    def mutation_problem = ("no mutation" unless @mutation)
    def command_problem = ("no command" unless @command)
    
  • FIRE-4: F114 crc/census/source_row.rb:47: def mismatch = @expected || @file.support ? "" : "unloaded"
  • FIRE-5: F015 db.rb:109
    def self.replace(path, db)
      live = live_row(db)
      refuse!(path, live) if live
      recreate(path)
    end
    def self.live_row(db) = db[:runs].find { |row| row[:finished_at].nil? }
    def self.refuse!(path, live) = raise(StaleError, "#{path}: live run #{live[:id]}")
    def self.recreate(path) = FileUtils.mv(path, "#{path}.stale")
    
  • FIRE-6: F110 system_model/tracer.rb:80
    def refuse_duplicates(found)
      duplicates = found.map(&:view_id).tally.select { |_id, count| count > 1 }.keys
      return if duplicates.empty?
      refuse("duplicate view id: #{duplicates.join(", ")}")
    end
    def refuse(message) = raise(RefusalError.new(message, 1))
    
  • FIRE-7: F116 implementation/mutation_check.rb:53
    def mutation_problem = ("path #{@mutation.path} is outside the target" unless inside_target?)
    def inside_target? = @mutation.path.start_with?("#{@root}/")
    
  • FIRE-8: F145 os-me/lib/os_me/blind_marker/reminder.rb:19: the same fixture as O ticket FIRE-2 (TEXT if (total % EVERY).zero?)
  • FIRE-9: F107 system_model/overlay_check.rb:34
    def unresolved(path, text, known)
      ids = text.scan(ID).uniq.reject { |id| resolves?(id, known) }
      ids.map { |id| ["overlay_unresolved", id, path] }
    end
    def resolves?(id, known) = known.include?(id) || id.start_with?("ext:")
    
  • FIRE-10: F084 crc/shares/verdict_split.rb:24: def gaps(row) = %w[job concept].select { |c| row.fetch(c).empty? }.map { |c| "#{row.fetch("path")}: empty #{c}" }
  • FIRE-11: F120 system_model/extractor/scripts.rb:16. In this fixture, the class reads only through Prism.parse_file.
    def facts = scanned.select { |_, found| found.decisions.any? || found.defs.any? }
    def scanned = @paths.to_h { |p| [p, Scan.new(Prism.parse_file(p).value)] }
    
  • FIRE-12: F144 os-backlog/bin/wakeup-poll:91. This fixture is an entry.
    projects.each do |project|
      last_seen = state.last_seen(project[:slug])
      if last_seen.nil?
        state.record(project[:slug], top)
        next
      end
      wake(project)
    end
    

Must-pass (44). This list has every FALSE site that has an E syntax shape, and the role cases:

  • PASS-1: F011: problems = packing.oversized(groups) followed by problems.empty? ? write(packing.call(groups)) : refused(problems)
  • PASS-2: F032: worklist = Worklist.new(JSON.parse(File.read(path))) followed by worklist.clear? ? 0 : 1
  • PASS-3: F130 (entry): def call(argv) holding result = sweep_for(argv.first).sweep and puts result.report.empty? ? "passed" : result.report
  • PASS-4: F127: { title: @options[:title] || fail_soft("requires --title") }
  • PASS-5: F046, F048: socket || raise(Unavailable)
  • PASS-6: F012: File.symlink?(path) ? [:skipped, entry("symlink")] : verdict(File.binread(path))
  • PASS-7: F013: def verdict(bytes) holding text = bytes.force_encoding(Encoding::UTF_8) and return [:problems, entry("not valid UTF-8")] unless text.valid_encoding?
  • PASS-8: F028: _o, err, status = Shell.capture3(cmd) followed by failure("patch does not apply: #{err}") unless status.success?
  • PASS-9: F019: report = build_report(tokens) followed by raise Thor::Error, "every token is PHANTOM" if report.all_phantom?
  • PASS-10: F031: result = run_gate(mode) followed by return report_failure(result) unless result.passed?
  • PASS-11: F029 (entry): status = Report.run(@path, argv) followed by return status unless status.nil?
  • PASS-12: F030: def run(mode) holding return checkpointed(mode) if mode
  • PASS-13: F021: path = file_path(token) followed by return { units: units_in(path) } if path, where file_path calls File.exist?
  • PASS-14: F033: warn result.err unless result.err.empty?
  • PASS-15: F123: def report(result) holding @session.fail_soft("bounced") unless result.claimed?
  • PASS-16: F124: def dispatch(tracker) holding return puts "no tracker" unless tracker && Tracker.valid?(tracker)
  • PASS-17: F125: def render(labels) holding if labels.empty? then puts "no scope labels" else ... end
  • PASS-18: F135: stamp = @options.slice(:row_id, :tier) followed by raise Error, "--row-id needs --tier" if stamp[:row_id] && stamp.size < 3
  • PASS-19: F136: def validate(options) holding missing = FLAGS.select { |k| options[k].nil? } and raise OptionParser::MissingArgument, missing.join(", ") unless missing.empty?
  • PASS-20: F142: def score(exchanges) holding raise "no exchanges" if exchanges.empty?
  • PASS-21: F143: rows.filter_map { |row| row["session_id"] if row["verdict"] }
  • PASS-22: F078: rows.reject { |row| row["new_path"].to_s.empty? }.to_h { |row| [row["path"], row["new_path"]] }
  • PASS-23: F071: @exclude.reject { |p| files.any? { |f| f.start_with?(p) } }.map { |p| { "problem" => "exclude matches no file", "prefix" => p } }
  • PASS-24: F007, F008: File.foreach(@file) { |line| in_history = line.chomp == @heading if line.start_with?("## "); yield line unless in_history }
  • PASS-25: F073, F085, F106, F108: raw.reject { |p| excluded?(p) }
  • PASS-26: F081: entries.select { |e| under_plugin?(e) }.filter_map { |e| finding(e) }
  • PASS-27: F090, F091: classes.select { |d| lineage.error?(d) }
  • PASS-28: F095: found.reject { |_cop, paths| paths.empty? }
  • PASS-29: F087: sections.select { !_1.empty? }
  • PASS-30: F017: numbered.empty? ? "No code line." : "```ruby\n#{numbered.join("\n")}\n```"
  • PASS-31: F039: path.include?("/hooks/") ? "hook" : "bin"
  • PASS-32: F004: tests.empty? ? NONE : tests.join(",")
  • PASS-33: F049, F052: run(argv) || 0
  • PASS-34: F035: existing = candidates.select { |d| File.directory?(d) } followed by lines << "Candidates: #{existing.join(", ")}" if existing.any?
  • PASS-35: F042: return Null.new unless check.configured?
  • PASS-36: F009: next resolution unless resolution.status == :missing
  • PASS-37: F121 (entry): tier = Wakeup.tier(labels) followed by if tier == :notify then append(line); next; end
  • PASS-38: F132: return none unless info && %i[forgejo github].include?(info[:kind])
  • PASS-39: F006: actual = File.open(path, &:gets).to_s.split("\t"), then return [] if actual == COLUMNS, then [{ "problem" => "header differs" }]
  • PASS-40: F138, F140: fresh = incoming.select { |r| known.add?(r.id) }
  • PASS-41: F086: required.reject { |p| File.exist?(File.join(@root, p)) }
  • PASS-42: F016: missing = deleted.reject { exist?(_1) } followed by raise HarnessError, "path does not exist: #{missing.join(", ")}" unless missing.empty?, where exist? calls File.exist?
  • PASS-43: Role, pure: the F010 fixture in a class whose body holds no effect call
  • PASS-44: Role, entry input (from the entry-scope rows F135 and F136 in the rule-v3 load-bearing table), with a fixture that is an entry: def call(argv) holding raise Usage, "needs a path" if argv.empty?

Owner sketch

Object File Owns
DomainEffectInEdge cops/structural/domain_effect_in_edge.rb Visits if/unless, ternaries, or, and select/reject/find blocks. Asks EdgeScope (from P), then the clause objects. Calls report with clause: and role:.
DomainEffectInEdge::Condition domain_effect_in_edge/condition.rb Answers ours (i to iii). Uses SameFileDefs and EffectReach (from P).
DomainEffectInEdge::CallerInput domain_effect_in_edge/caller_input.rb Answers caller input (1, 2). It walks locals, block elements, and the ivars that initialize sets from its parameters.
DomainEffectInEdge::Effect domain_effect_in_edge/effect.rb Answers verdict effect (a to d) for a branch or a selection.
EffectInModel::EffectCall (changed) cops/structural/effect_in_model/effect_call.rb Reads FileFormConstants from cop_config, with the default YAML JSON.

Non-goals

The cop does not find these shapes (from smell analysis section 7 and the rule-v3 misses):

  • A verdict mapped to a second verdict (F117). Cop O catches F117 by its literal operand.
  • Effects done by a write, not by a return.
  • Refusals raised in a callee in another file.
  • F115 row.fetch("commit", "").empty? || row.fetch("new_path").empty? (TRUE). It is a label pair with F143 (FALSE), and their facts are the same. This needs a ruling (smell analysis follow-up 5).
  • Callee roles across files (rule object, reader, or value). E3 uses only the one-file forms above. Selections that depend on a reader class's meaning in another file are missed.
  • Reads through ENV, Sequel, or a network client do not make a class an adapter, unless the class also holds an EffectCall.
  • No autocorrect (tier c).

Blocked by

#1510 (its orchestrator step reruns DomainOperandInEdge, and both tickets touch EdgeScope; #1509 is closed)

Origin

  • Trigger: 2026-11-02 data-flow fact rounds over 147 labeled decision sites; smell analysis docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md
  • Improvised this session: none
  • Chain: syntax detector overfit (15% precision on unseen code) ← A19 line is drawn on data origin, not syntax ← DESIGN docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md
  • Root candidate: none found; searched DomainEffectInEdge, EdgeScope, EffectInModel, EffectCall, A19, edge, adapter, verdict, decision-in-adapter
  • Where: plugins/os-sdlc/lib/os_sdlc/system_model/class_facts/io_reach.rb
  • Session: 394c0068-e8aa-449e-95d1-dad679bda47d
  • Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/394c0068-e8aa-449e-95d1-dad679bda47d.jsonl

VERDICT: CREATE — no open ticket covers a domain effect decided inside an edge; nearest #1507 and #1351 cover EffectInModel.

## Problem An edge object (an adapter or an entry) turns what it read into a domain outcome: a verdict (refuse, fail, report a problem), a selection of work (ready, unseen, open items), or a next step of our process. The outcome is our rule. Because it is inside the adapter, its test needs the resource. An edge that only reports what the resource holds, or that the resource failed, is translation. Evidence: `docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md` (rule R3, section 7, cop E) and `docs/system-design-format/proof/2026-11-02-detector-a19/facts/rule-v3-notes.md` (clauses E1 to E4). On 147 labeled sites, the v3 definition of E fires on 12 TRUE sites and 0 FALSE sites. No cop finds this shape. `Sdlc/Structural/EffectInModel` is disabled in this repo because no `LedgerPath` is set (#1507). This cop is the third of three. It needs the shared objects from the P ticket (`Sdlc/Structural/PrecedenceInEdge`: `EdgeScope`, `EffectReach`). Build P first. ## Reproduce n/a: new cop, nothing to reproduce ## Approach Add `RuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < RecordingCop`. **Site role (same text in the P, O, and E tickets).** The cop reports only in an edge, and it gets the role from the code, not from a ledger: - adapter: the nearest enclosing `class` or `module` body holds at least one call that `EffectInModel::EffectCall#label` names. For code outside all classes, use the file's top level. The cop gives `EffectCall` its own `EffectConstants` default: `File Dir IO FileUtils YAML JSON Open3 Process Kernel`. This is the `EffectCall` default plus `FileUtils`. - entry: `EntryPoint.file?(path)` is true. That is, the first line starts with `#!`, or the file is in a folder named `bin`. - In an entry, if a test's values come only from the enclosing method's parameters, `ARGV`, or literals, and its skipped path calls no effect, the test is input, not a decision (A19 NOT). **Required widening.** F120's class reads its files only through `Prism.parse_file`. Today `EffectCall` treats only `YAML` and `JSON` `*_file` methods as file reads, and these two constants are hard-coded. Add a `FileFormConstants` key to `EffectCall`, with the default `YAML JSON`, so that `EffectInModel` does not change. `EdgeScope` passes `YAML JSON Prism`. This widens the role scope for P and O too, so run the P and O whole-tree checks again (see the AC). **No ledger.** The cop does not read `LayerLedger`. Existing behavior: `EffectInModel` reports nothing when `LedgerPath` is not set (#1507). When `LedgerPath` names a missing file, `LayerLedger::Ledger#role_by_const` raises `RuboCop::Error`. The ledger cannot give roles for this cop, because `LayerMap.cpath_by_path` maps only files under os-sdlc's own `lib/` through `OsSdlc.loader`. It cannot map os-me, os-backlog, `bin/` scripts, or a foreign project. Because the role comes from the code, the cop has no "not configured" state. Thus it never does nothing silently. **ADR constraints.** - ADR-0298: register the cop only in `plugins/os-sdlc/.rubocop.yml`. os-sdlc layers this config at run time and installs nothing into target projects. - ADR-0082: Sdlc cops apply to every project through the layered config. The cop must give correct results on foreign codebases with no ledger and no per-project setup. - ADR-0090: Zeitwerk. `cops/structural/domain_effect_in_edge.rb` holds `Sdlc::Structural::DomainEffectInEdge`. **Terms.** - *Ours* (a condition computed in this file) is one of these: - (i) a call to a same-file predicate method; - (ii) a presence test (truthiness, `nil?`, `empty?`, `any?`, `none?`) on a local that the method assigns directly from a same-file method call, on that same-file method, on an ivar, or on a message with no arguments to an ivar. The value must reach no effect call through same-file methods (`EffectReach`); - (iii) a comparison over an arithmetic or aggregate result written in this method (`%`, `tally`, `count`, `size`, `max`). - *Caller input* is one of these: - (1) a bare method parameter, or a message to one. Literal-key slot reads (`row.fetch("job")`, or `row.fetch(c)` where `c` iterates a literal list) are fields of a parsed row, not caller input; - (2) a value whose receiver chain, through locals and block elements of this method, ends at an ivar set from an `initialize` parameter. - *Verdict effect* is one of these: - (a) it raises, directly or through a same-file method whose body raises (`raise(X.new(...))` included); - (b) it returns a string literal (plain or interpolated) under the condition as the method's value; - (c) it maps the selected items into strings or tagged rows (an array or hash that holds a string or symbol literal tag); - (d) it returns a constant assigned in this file under a modifier `if`. **Value rule (R3, effect).** The cop reports a site when one of these clauses holds: - **E1 (verdict choice).** A ternary or `a || b`. Each candidate is a same-file method call or a plain (not interpolated) string or symbol literal. For `a || b`, each candidate is a same-file method call. The condition is *ours*. E1 does not apply the caller-input test, because v3 E1 allows ivars. - **E2 (our computation gives a verdict).** The condition is *ours*, it is not *caller input*, and the branch has a *verdict effect*. - **E3 (a reader's return selects or starts a step).** Either form: - (i) A `select`, `reject`, or `find` over a same-file collection. Its block tests the presence (`any?`, `empty?`, `none?`, `nil?`) of an argument-free message to the block element. - (ii) Inside an iteration, a presence test on a local assigned from a message to another object, where the taken branch runs two or more statements and ends with `next` or `return`. In both forms, the test is not *caller input*. - **E4 (a parsed field gives a verdict).** A presence test on a literal-key slot read that is not *caller input*, with *verdict effect* (c) only. The terms *ours*, *caller input*, and *verdict effect* are new facts. No existing helper computes them. They live in the objects in the owner sketch. The cop records `clause:` (`e1` to `e4`) and `role:` through `RecordingCop#report`. **Shared helpers (after #1509 and #1512).** - `EdgeScope#role(node, values)` gives `:entry` only to code outside every class and module body (#1512). A class inside an entry file takes the adapter role when its body holds an effect call, otherwise no edge role. - `EdgeScope#input?` tests only that the values come from parameters, `ARGV`, or literals. The cop tests the second entry condition (the skipped path calls no effect) itself, with `EffectReach#labels`. - The cop passes the tested values as `values` to `EdgeScope#role`, not the whole node. PASS-44 depends on this. - Use `QueryMethodReachesWrite::SameFileDefs`, as `precedence_in_edge.rb` does. Use `PrecedenceInEdge::EFFECT_CONSTANTS`; do not copy the list. - `PrecedenceInEdge` builds one `effect_config` hash for both `EffectReach` and `EdgeScope` (`precedence_in_edge.rb:15-19`). Add `FileFormConstants` only to the config that `EdgeScope` gives to `EffectCall`. `EffectReach` keeps the `YAML JSON` default, so only the role scope widens. Required work: 1. Add the cop class `RuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < RecordingCop` — check: present `class DomainEffectInEdge` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rb` 2. Add `DomainEffectInEdge::Condition` — check: present `class Condition` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/condition.rb` 3. Add `DomainEffectInEdge::CallerInput` — check: present `class CallerInput` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/caller_input.rb` 4. Add `DomainEffectInEdge::Effect` — check: present `class Effect` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge/effect.rb` 5. Make `EffectInModel::EffectCall` read `FileFormConstants` from `cop_config`, with the default `YAML JSON` — check: present `FileFormConstants` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/effect_in_model/effect_call.rb` 6. Make `EdgeScope` pass `YAML JSON Prism` as `FileFormConstants` — check: present `Prism` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/edge_scope.rb` 7. Register the cop with no `Exclude` key — check: present `Sdlc/Structural/DomainEffectInEdge:` in `plugins/os-sdlc/.rubocop.yml` 8. Add the cop test with the must-fire and must-pass fixtures below — check: present `DomainEffectInEdge` in `plugins/os-sdlc/tests/cops/domain_effect_in_edge_test.rb` 9. Add the lesson YAML with the text in the Lesson subsection — check: present `key: Sdlc/Structural/DomainEffectInEdge` in `plugins/os-sdlc/lint-lessons/sdlc-structural-domain-effect-in-edge.yml` 10. Reuse the shared effect constant list — check: present `PrecedenceInEdge::EFFECT_CONSTANTS` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rb` 11. Add `FileFormConstants` only inside `EdgeScope`, not to the shared `effect_config` hash — check: absent `FileFormConstants` in `plugins/os-sdlc/lib/os_sdlc/cops/structural/precedence_in_edge.rb` Orchestrator work: Whole-tree run: run the cop alone with the os-sdlc config over every Ruby file under `plugins/`, including `bin/` scripts and the os-me and os-backlog plugins; put a table in the PR with one row for each hit (site, clause, TRUE or FALSE, and the reason by the swap test); the run must have zero false hits; for each false hit, either make the value rule tighter and add the hit as a must-pass fixture, or stop and file a ticket with `/os-backlog:capture`; also list each must-fire site that the real-tree run does not report, with the fact that blocks it. After the `FileFormConstants` widening, run `Sdlc/Structural/PrecedenceInEdge` and `Sdlc/Structural/DomainOperandInEdge` over `plugins/` again; neither run may have a new false hit. ### Lesson The shared lesson core comes from the P ticket (`Sdlc/Structural/PrecedenceInEdge`). The `why` and `correction` values and the opening of `decision` below are copied word for word from it. ```yaml key: Sdlc/Structural/DomainEffectInEdge signal: >- An edge turns what it read into a verdict (refuse, fail, a problem), a selection of work (ready, unseen, open), or a next step, by a condition that it computes itself. sign_of: >- A domain outcome inside the object that reads the resource: the edge decides what the data means for our process, not only what the data is. why: >- Lens A19: a domain rule inside an edge needs the resource to run, so its test cannot use literal inputs. Lens principle 9: "Code that decides takes values and returns values." Source: Gary Bernhardt, "Functional Core, Imperative Shell" (2012). An edit that inverts the rule then fails no test. decision: >- Replace the resource with another one that holds the same facts. If the rule survives, it is a domain rule. Here, ask: would a different resource with the same facts give the same verdict, selection, or step? If yes, the outcome is ours. If you find no smell at this site (the branch only reports what the resource holds or that it failed), file a false-positive ticket with the site and one must-fire fixture from this cop's tests. Do not change the code to silence the cop. correction: >- The adapter returns the values it read, in the resource's terms. A decision object, or the value that owns the data, picks. Test the rule with literal inputs. example: before: |- class InvoiceFile def problems rows.select { |row| row.fetch("paid_at").empty? }.map { |row| "#{row.fetch("id")}: unpaid" } end private def rows = File.readlines(@path, chomp: true).map { |line| %w[id paid_at].zip(line.split(",", -1)).to_h } end after: |- class InvoiceFile def rows = File.readlines(@path, chomp: true).map { |line| %w[id paid_at].zip(line.split(",", -1)).to_h } end Unpaid = Data.define(:rows) do def problems = rows.select { |row| row.fetch("paid_at").empty? }.map { |row| "#{row.fetch("id")}: unpaid" } end ``` ## Acceptance criteria 1. Given the repository after the change, when eager load runs, then `Sdlc/Structural/DomainEffectInEdge` is registered from `plugins/os-sdlc/lib/os_sdlc/cops/structural/domain_effect_in_edge.rb` as `RuboCop::Cop::Sdlc::Structural::DomainEffectInEdge < RecordingCop` and loading passes (ADR-0090). 2. Given any fixture that makes the cop report, when it reports, then the offense message is exactly `An edge turns what it read into a verdict, a selection of work, or a next step. Return the values it read; a decision object or the value that owns the data decides.` and the test asserts it verbatim. 3. Given `plugins/os-sdlc/.rubocop.yml`, when it is read, then it has `Sdlc/Structural/DomainEffectInEdge: Enabled: true` with no `Exclude` key (ADR-0298). 4. Given `plugins/os-sdlc/tests/cops/domain_effect_in_edge_test.rb`, when it runs, then every fixture goes through `CopHelpers#offenses_for`. 5. Given each of the 12 must-fire fixtures FIRE-1 to FIRE-12 listed below, when the cop runs, then it reports exactly one offense. 6. Given each of the 44 must-pass fixtures PASS-1 to PASS-44 listed below, when the cop runs, then it reports no offense. 7. Given a fixture that the cop wrongly reports, when the cop is made tighter, then the change is to the value rule and no `Exclude` list, disable comment, or site list is added. 8. Given `EffectCall` with no `FileFormConstants` setting, when the `EffectInModel` tests run, then they pass with no change to them. 9. Given `plugins/os-sdlc/lint-lessons/sdlc-structural-domain-effect-in-edge.yml` and `sdlc-structural-precedence-in-edge.yml`, when the `why` value, the first two sentences of `decision`, and the `correction` value are diffed, then the diff is empty and the PR shows it. 10. Given the full suite and the os-sdlc rubocop config, when they run on the new files, then the suite is green and the new files pass. The cop covers the whole R3 shape set above, not only the sample sites. In all fixtures, "adapter class" means that the class body also holds `def raw = File.read(@path)`. "Entry" means that the fixture path is `bin/tool`. **Must-fire (12). These are all the R3 TRUE sites that the v3 definition catches:** - FIRE-1: F010 `crc/shares/verdict_split.rb:18` ```ruby def call problems = rows.flat_map { |row| gaps(row) } problems.empty? ? pack(rows) : refused(problems) end def gaps(row) = %w[job concept].select { |c| row.fetch(c).empty? }.map { |c| "empty #{c}" } ``` - FIRE-2: F026 `implementation/mutation_check.rb:17` ```ruby def report line = precondition_failure line ? failure(line) : run end def precondition_failure = ("no mutation" unless @mutation) ``` - FIRE-3: F050 `implementation/mutation_check.rb:46` ```ruby def precondition_failure = mutation_problem || command_problem def mutation_problem = ("no mutation" unless @mutation) def command_problem = ("no command" unless @command) ``` - FIRE-4: F114 `crc/census/source_row.rb:47`: `def mismatch = @expected || @file.support ? "" : "unloaded"` - FIRE-5: F015 `db.rb:109` ```ruby def self.replace(path, db) live = live_row(db) refuse!(path, live) if live recreate(path) end def self.live_row(db) = db[:runs].find { |row| row[:finished_at].nil? } def self.refuse!(path, live) = raise(StaleError, "#{path}: live run #{live[:id]}") def self.recreate(path) = FileUtils.mv(path, "#{path}.stale") ``` - FIRE-6: F110 `system_model/tracer.rb:80` ```ruby def refuse_duplicates(found) duplicates = found.map(&:view_id).tally.select { |_id, count| count > 1 }.keys return if duplicates.empty? refuse("duplicate view id: #{duplicates.join(", ")}") end def refuse(message) = raise(RefusalError.new(message, 1)) ``` - FIRE-7: F116 `implementation/mutation_check.rb:53` ```ruby def mutation_problem = ("path #{@mutation.path} is outside the target" unless inside_target?) def inside_target? = @mutation.path.start_with?("#{@root}/") ``` - FIRE-8: F145 `os-me/lib/os_me/blind_marker/reminder.rb:19`: the same fixture as O ticket FIRE-2 (`TEXT if (total % EVERY).zero?`) - FIRE-9: F107 `system_model/overlay_check.rb:34` ```ruby def unresolved(path, text, known) ids = text.scan(ID).uniq.reject { |id| resolves?(id, known) } ids.map { |id| ["overlay_unresolved", id, path] } end def resolves?(id, known) = known.include?(id) || id.start_with?("ext:") ``` - FIRE-10: F084 `crc/shares/verdict_split.rb:24`: `def gaps(row) = %w[job concept].select { |c| row.fetch(c).empty? }.map { |c| "#{row.fetch("path")}: empty #{c}" }` - FIRE-11: F120 `system_model/extractor/scripts.rb:16`. In this fixture, the class reads only through `Prism.parse_file`. ```ruby def facts = scanned.select { |_, found| found.decisions.any? || found.defs.any? } def scanned = @paths.to_h { |p| [p, Scan.new(Prism.parse_file(p).value)] } ``` - FIRE-12: F144 `os-backlog/bin/wakeup-poll:91`. This fixture is an entry. ```ruby projects.each do |project| last_seen = state.last_seen(project[:slug]) if last_seen.nil? state.record(project[:slug], top) next end wake(project) end ``` **Must-pass (44). This list has every FALSE site that has an E syntax shape, and the role cases:** - PASS-1: F011: `problems = packing.oversized(groups)` followed by `problems.empty? ? write(packing.call(groups)) : refused(problems)` - PASS-2: F032: `worklist = Worklist.new(JSON.parse(File.read(path)))` followed by `worklist.clear? ? 0 : 1` - PASS-3: F130 (entry): `def call(argv)` holding `result = sweep_for(argv.first).sweep` and `puts result.report.empty? ? "passed" : result.report` - PASS-4: F127: `{ title: @options[:title] || fail_soft("requires --title") }` - PASS-5: F046, F048: `socket || raise(Unavailable)` - PASS-6: F012: `File.symlink?(path) ? [:skipped, entry("symlink")] : verdict(File.binread(path))` - PASS-7: F013: `def verdict(bytes)` holding `text = bytes.force_encoding(Encoding::UTF_8)` and `return [:problems, entry("not valid UTF-8")] unless text.valid_encoding?` - PASS-8: F028: `_o, err, status = Shell.capture3(cmd)` followed by `failure("patch does not apply: #{err}") unless status.success?` - PASS-9: F019: `report = build_report(tokens)` followed by `raise Thor::Error, "every token is PHANTOM" if report.all_phantom?` - PASS-10: F031: `result = run_gate(mode)` followed by `return report_failure(result) unless result.passed?` - PASS-11: F029 (entry): `status = Report.run(@path, argv)` followed by `return status unless status.nil?` - PASS-12: F030: `def run(mode)` holding `return checkpointed(mode) if mode` - PASS-13: F021: `path = file_path(token)` followed by `return { units: units_in(path) } if path`, where `file_path` calls `File.exist?` - PASS-14: F033: `warn result.err unless result.err.empty?` - PASS-15: F123: `def report(result)` holding `@session.fail_soft("bounced") unless result.claimed?` - PASS-16: F124: `def dispatch(tracker)` holding `return puts "no tracker" unless tracker && Tracker.valid?(tracker)` - PASS-17: F125: `def render(labels)` holding `if labels.empty? then puts "no scope labels" else ... end` - PASS-18: F135: `stamp = @options.slice(:row_id, :tier)` followed by `raise Error, "--row-id needs --tier" if stamp[:row_id] && stamp.size < 3` - PASS-19: F136: `def validate(options)` holding `missing = FLAGS.select { |k| options[k].nil? }` and `raise OptionParser::MissingArgument, missing.join(", ") unless missing.empty?` - PASS-20: F142: `def score(exchanges)` holding `raise "no exchanges" if exchanges.empty?` - PASS-21: F143: `rows.filter_map { |row| row["session_id"] if row["verdict"] }` - PASS-22: F078: `rows.reject { |row| row["new_path"].to_s.empty? }.to_h { |row| [row["path"], row["new_path"]] }` - PASS-23: F071: `@exclude.reject { |p| files.any? { |f| f.start_with?(p) } }.map { |p| { "problem" => "exclude matches no file", "prefix" => p } }` - PASS-24: F007, F008: `File.foreach(@file) { |line| in_history = line.chomp == @heading if line.start_with?("## "); yield line unless in_history }` - PASS-25: F073, F085, F106, F108: `raw.reject { |p| excluded?(p) }` - PASS-26: F081: `entries.select { |e| under_plugin?(e) }.filter_map { |e| finding(e) }` - PASS-27: F090, F091: `classes.select { |d| lineage.error?(d) }` - PASS-28: F095: `found.reject { |_cop, paths| paths.empty? }` - PASS-29: F087: `sections.select { !_1.empty? }` - PASS-30: F017: ``numbered.empty? ? "No code line." : "```ruby\n#{numbered.join("\n")}\n```"`` - PASS-31: F039: `path.include?("/hooks/") ? "hook" : "bin"` - PASS-32: F004: `tests.empty? ? NONE : tests.join(",")` - PASS-33: F049, F052: `run(argv) || 0` - PASS-34: F035: `existing = candidates.select { |d| File.directory?(d) }` followed by `lines << "Candidates: #{existing.join(", ")}" if existing.any?` - PASS-35: F042: `return Null.new unless check.configured?` - PASS-36: F009: `next resolution unless resolution.status == :missing` - PASS-37: F121 (entry): `tier = Wakeup.tier(labels)` followed by `if tier == :notify then append(line); next; end` - PASS-38: F132: `return none unless info && %i[forgejo github].include?(info[:kind])` - PASS-39: F006: `actual = File.open(path, &:gets).to_s.split("\t")`, then `return [] if actual == COLUMNS`, then `[{ "problem" => "header differs" }]` - PASS-40: F138, F140: `fresh = incoming.select { |r| known.add?(r.id) }` - PASS-41: F086: `required.reject { |p| File.exist?(File.join(@root, p)) }` - PASS-42: F016: `missing = deleted.reject { exist?(_1) }` followed by `raise HarnessError, "path does not exist: #{missing.join(", ")}" unless missing.empty?`, where `exist?` calls `File.exist?` - PASS-43: Role, pure: the F010 fixture in a class whose body holds no effect call - PASS-44: Role, entry input (from the entry-scope rows F135 and F136 in the rule-v3 load-bearing table), with a fixture that is an entry: `def call(argv)` holding `raise Usage, "needs a path" if argv.empty?` ## Owner sketch | Object | File | Owns | | --- | --- | --- | | `DomainEffectInEdge` | `cops/structural/domain_effect_in_edge.rb` | Visits `if`/`unless`, ternaries, `or`, and `select`/`reject`/`find` blocks. Asks `EdgeScope` (from P), then the clause objects. Calls `report` with `clause:` and `role:`. | | `DomainEffectInEdge::Condition` | `domain_effect_in_edge/condition.rb` | Answers *ours* (i to iii). Uses `SameFileDefs` and `EffectReach` (from P). | | `DomainEffectInEdge::CallerInput` | `domain_effect_in_edge/caller_input.rb` | Answers *caller input* (1, 2). It walks locals, block elements, and the ivars that `initialize` sets from its parameters. | | `DomainEffectInEdge::Effect` | `domain_effect_in_edge/effect.rb` | Answers *verdict effect* (a to d) for a branch or a selection. | | `EffectInModel::EffectCall` (changed) | `cops/structural/effect_in_model/effect_call.rb` | Reads `FileFormConstants` from `cop_config`, with the default `YAML JSON`. | ## Non-goals The cop does not find these shapes (from smell analysis section 7 and the rule-v3 misses): - A verdict mapped to a second verdict (F117). Cop O catches F117 by its literal operand. - Effects done by a write, not by a return. - Refusals raised in a callee in another file. - F115 `row.fetch("commit", "").empty? || row.fetch("new_path").empty?` (TRUE). It is a label pair with F143 (FALSE), and their facts are the same. This needs a ruling (smell analysis follow-up 5). - Callee roles across files (rule object, reader, or value). E3 uses only the one-file forms above. Selections that depend on a reader class's meaning in another file are missed. - Reads through `ENV`, Sequel, or a network client do not make a class an adapter, unless the class also holds an `EffectCall`. - No autocorrect (tier c). ## Blocked by #1510 (its orchestrator step reruns DomainOperandInEdge, and both tickets touch EdgeScope; #1509 is closed) ## Origin - Trigger: 2026-11-02 data-flow fact rounds over 147 labeled decision sites; smell analysis docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md - Improvised this session: none - Chain: syntax detector overfit (15% precision on unseen code) ← A19 line is drawn on data origin, not syntax ← DESIGN docs/system-design-format/proof/2026-11-02-smell-analysis-decision-in-adapter.md - Root candidate: none found; searched DomainEffectInEdge, EdgeScope, EffectInModel, EffectCall, A19, edge, adapter, verdict, decision-in-adapter - Where: plugins/os-sdlc/lib/os_sdlc/system_model/class_facts/io_reach.rb - Session: 394c0068-e8aa-449e-95d1-dad679bda47d - Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/394c0068-e8aa-449e-95d1-dad679bda47d.jsonl VERDICT: CREATE — no open ticket covers a domain effect decided inside an edge; nearest #1507 and #1351 cover EffectInModel.
Author
Owner

Work started on branch ticket-1511 via /os-sdlc:implement (session a5c3e206).

Work started on branch ticket-1511 via /os-sdlc:implement (session a5c3e206).
Author
Owner

Resolution

Done: Cop Sdlc/Structural/DomainEffectInEdge (A19 R3) with lint lesson: reports an edge (adapter by effect call, entry by EntryPoint) that turns what it read into a verdict, a selection, or a next step; EffectCall gains FileFormConstants (default YAML JSON; EdgeScope passes YAML JSON Prism); registered in plugins/os-sdlc/.rubocop.yml only.

Evidence: Runner implementation 221 implementation_complete; 19 dispatches, 2 retries; suite-check pass (suite 50 s); contract-auditor pass (opus); committed by the Runner on branch ticket-1511.

Follow-ups: One LENS line (A2: PrecedenceInEdge::Candidate.unwrap now called from two cop concepts) is being challenged; if JUSTIFIED it becomes a 'Lens proposal:' ticket via /os-backlog:capture. No other follow-ups.

## Resolution **Done:** Cop Sdlc/Structural/DomainEffectInEdge (A19 R3) with lint lesson: reports an edge (adapter by effect call, entry by EntryPoint) that turns what it read into a verdict, a selection, or a next step; EffectCall gains FileFormConstants (default YAML JSON; EdgeScope passes YAML JSON Prism); registered in plugins/os-sdlc/.rubocop.yml only. **Evidence:** Runner implementation 221 implementation_complete; 19 dispatches, 2 retries; suite-check pass (suite 50 s); contract-auditor pass (opus); committed by the Runner on branch ticket-1511. **Follow-ups:** One LENS line (A2: PrecedenceInEdge::Candidate.unwrap now called from two cop concepts) is being challenged; if JUSTIFIED it becomes a 'Lens proposal:' ticket via /os-backlog:capture. No other follow-ups.
jared closed this issue 2026-11-03 14:00:59 +00:00
Author
Owner

Follow-up from the close comment resolved: the A2 LENS line was challenged (verdict: code defect found, JUSTIFIED overturned). Filed #1523 (Lens proposal: lift cross-concept nested cop helpers to top level). Signal recorded in lens-signals/A2.jsonl (main ef991fc4).

Follow-up from the close comment resolved: the A2 LENS line was challenged (verdict: code defect found, JUSTIFIED overturned). Filed #1523 (Lens proposal: lift cross-concept nested cop helpers to top level). Signal recorded in lens-signals/A2.jsonl (main ef991fc4).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#1511
No description provided.