@onlooker-community/ecosystem 0.34.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/.claude/skills/writing-tests/SKILL.md +27 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.github/workflows/release.yml +14 -14
  4. package/.release-please-manifest.json +7 -7
  5. package/AGENTS.md +116 -0
  6. package/CHANGELOG.md +175 -0
  7. package/CLAUDE.md +5 -0
  8. package/docs/lesson-promotion-pipeline.md +6 -6
  9. package/docs/superpowers/plans/2026-08-10-lesson-confirmation.md +1273 -0
  10. package/docs/superpowers/plans/2026-08-11-lesson-judging.md +1232 -0
  11. package/docs/superpowers/plans/2026-08-11-lesson-unconfirm.md +458 -0
  12. package/docs/superpowers/plans/2026-08-12-author-key.md +567 -0
  13. package/docs/superpowers/plans/2026-08-13-approved-pool.md +747 -0
  14. package/docs/superpowers/plans/2026-08-13-librarian-cluster.md +392 -0
  15. package/docs/superpowers/plans/2026-08-14-criterion-scores-consumers.md +1374 -0
  16. package/docs/superpowers/plans/2026-08-14-criterion-scores-schema.md +277 -0
  17. package/docs/superpowers/plans/2026-08-16-cartographer-undocumented-entity.md +741 -0
  18. package/docs/superpowers/specs/2026-08-10-lesson-confirmation-design.md +213 -0
  19. package/docs/superpowers/specs/2026-08-11-lesson-judging-design.md +267 -0
  20. package/docs/superpowers/specs/2026-08-11-lesson-unconfirm-design.md +152 -0
  21. package/docs/superpowers/specs/2026-08-12-author-key-design.md +317 -0
  22. package/docs/superpowers/specs/2026-08-13-approved-pool-design.md +232 -0
  23. package/docs/superpowers/specs/2026-08-14-criterion-scores-design.md +254 -0
  24. package/docs/superpowers/specs/2026-08-16-cartographer-undocumented-entity-design.md +249 -0
  25. package/package.json +2 -2
  26. package/plugins/assayer/.claude-plugin/plugin.json +1 -1
  27. package/plugins/assayer/CHANGELOG.md +7 -0
  28. package/plugins/assayer/scripts/lib/assayer-config.sh +6 -0
  29. package/plugins/cartographer/.claude-plugin/plugin.json +1 -1
  30. package/plugins/cartographer/CHANGELOG.md +54 -0
  31. package/plugins/cartographer/README.md +56 -1
  32. package/plugins/cartographer/config.json +7 -1
  33. package/plugins/cartographer/scripts/hooks/cartographer-post-write.sh +4 -4
  34. package/plugins/cartographer/scripts/hooks/cartographer-session-start.sh +7 -4
  35. package/plugins/cartographer/scripts/lib/cartographer-collect.sh +8 -1
  36. package/plugins/cartographer/scripts/lib/cartographer-config.sh +26 -0
  37. package/plugins/cartographer/scripts/lib/cartographer-events.sh +90 -0
  38. package/plugins/cartographer/scripts/lib/cartographer-filter.sh +105 -0
  39. package/plugins/cartographer/scripts/lib/cartographer-omission.sh +130 -0
  40. package/plugins/cartographer/scripts/lib/cartographer-resolve.sh +132 -0
  41. package/plugins/cartographer/scripts/run-audit.sh +136 -53
  42. package/plugins/cartographer/skills/cartographer/SKILL.md +33 -9
  43. package/plugins/curator/.claude-plugin/plugin.json +1 -1
  44. package/plugins/curator/CHANGELOG.md +7 -0
  45. package/plugins/curator/scripts/lib/curator-emit.sh +2 -1
  46. package/plugins/historian/.claude-plugin/plugin.json +1 -1
  47. package/plugins/historian/CHANGELOG.md +7 -0
  48. package/plugins/historian/scripts/lib/historian-emit.sh +2 -1
  49. package/plugins/librarian/.claude-plugin/plugin.json +1 -1
  50. package/plugins/librarian/CHANGELOG.md +71 -0
  51. package/plugins/librarian/config.json +30 -1
  52. package/plugins/librarian/docs/adr/002-agent-definitions-are-shared-assets.md +82 -0
  53. package/plugins/librarian/scripts/hooks/librarian-session-end.sh +28 -0
  54. package/plugins/librarian/scripts/hooks/librarian-session-start.sh +42 -12
  55. package/plugins/librarian/scripts/lib/librarian-author-key.sh +279 -0
  56. package/plugins/librarian/scripts/lib/librarian-cli.sh +346 -2
  57. package/plugins/librarian/scripts/lib/librarian-emit.sh +2 -1
  58. package/plugins/librarian/scripts/lib/librarian-lesson-judge.sh +349 -0
  59. package/plugins/librarian/scripts/lib/librarian-lesson-promote.sh +225 -0
  60. package/plugins/librarian/scripts/lib/librarian-lesson-review.sh +312 -0
  61. package/plugins/librarian/scripts/lib/librarian-lesson-rubric.sh +56 -0
  62. package/plugins/librarian/scripts/lib/librarian-lesson-storage.sh +82 -13
  63. package/plugins/librarian/scripts/lib/librarian-lesson-transform.sh +6 -1
  64. package/plugins/librarian/scripts/lib/librarian-lesson-validate.sh +131 -62
  65. package/plugins/librarian/skills/librarian/SKILL.md +98 -1
  66. package/plugins/tribunal/.claude-plugin/plugin.json +1 -1
  67. package/plugins/tribunal/CHANGELOG.md +63 -0
  68. package/plugins/tribunal/agents/tribunal-judge-adversarial.md +34 -0
  69. package/plugins/tribunal/agents/tribunal-judge-security.md +33 -0
  70. package/plugins/tribunal/agents/tribunal-judge-standard.md +12 -0
  71. package/plugins/tribunal/config.json +1 -0
  72. package/plugins/tribunal/scripts/lib/tribunal-aggregate.sh +154 -9
  73. package/plugins/tribunal/scripts/lib/tribunal-gate.sh +195 -16
  74. package/plugins/tribunal/skills/tribunal/SKILL.md +5 -3
  75. package/scripts/lib/prompt-rules.sh +6 -1
  76. package/test/bats/archivist-inject.bats +1 -1
  77. package/test/bats/assayer-extract.bats +2 -2
  78. package/test/bats/bursar-session-start.bats +3 -3
  79. package/test/bats/cartographer-config.bats +43 -0
  80. package/test/bats/cartographer-events.bats +267 -0
  81. package/test/bats/cartographer-filter.bats +169 -0
  82. package/test/bats/cartographer-lock.bats +3 -3
  83. package/test/bats/cartographer-omission.bats +202 -0
  84. package/test/bats/cartographer-resolve.bats +287 -0
  85. package/test/bats/cartographer-run-audit.bats +325 -0
  86. package/test/bats/compass-sanitizer.bats +11 -11
  87. package/test/bats/compass-transcript.bats +2 -2
  88. package/test/bats/config.bats +15 -15
  89. package/test/bats/curator-session-start.bats +10 -3
  90. package/test/bats/emit-payload-default.bats +52 -0
  91. package/test/bats/governor-ledger.bats +1 -1
  92. package/test/bats/historian-prompt-submit.bats +1 -1
  93. package/test/bats/inspector-post-write-hook.bats +4 -4
  94. package/test/bats/librarian-author-key.bats +477 -0
  95. package/test/bats/librarian-cli.bats +16 -16
  96. package/test/bats/librarian-lesson-judge.bats +884 -0
  97. package/test/bats/librarian-lesson-promote.bats +552 -0
  98. package/test/bats/librarian-lesson-review.bats +900 -0
  99. package/test/bats/librarian-lesson-transform.bats +17 -3
  100. package/test/bats/librarian-session-end.bats +161 -0
  101. package/test/bats/librarian-session-start.bats +36 -2
  102. package/test/bats/lineage-config.bats +1 -1
  103. package/test/bats/lineage-redact.bats +5 -5
  104. package/test/bats/session-tracker.bats +4 -4
  105. package/test/bats/tribunal-aggregate.bats +354 -1
  106. package/test/bats/tribunal-gate.bats +631 -0
  107. package/test/bats/tribunal-judge-agents.bats +119 -0
  108. package/test/bats/tribunal-jury.bats +1 -1
  109. package/test/bats/turn-tracker.bats +1 -1
  110. package/test/bats/warden-sanitizer.bats +3 -3
  111. package/test/bats/worktree-tracker.bats +2 -2
  112. package/test/node/lesson-validate-agreement.test.mjs +35 -6
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env bats
2
2
 
3
+ # `run --separate-stderr` (used below) requires bats >= 1.5.0.
4
+ bats_require_minimum_version 1.5.0
5
+
3
6
  setup() {
4
7
  source "${BATS_TEST_DIRNAME}/../helpers/setup.bash"
5
8
  setup_test_env
@@ -93,3 +96,631 @@ NO_META='{}'
93
96
  [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
94
97
  [ "$(printf '%s' "$out" | jq -r '.reason')" = "meta_override" ]
95
98
  }
99
+
100
+ RUBRIC_FLOOR='{"criteria":[{"name":"correctness","weight":0.5,"min_pass":0.7},{"name":"safety","weight":0.5,"min_pass":0.8}]}'
101
+
102
+ @test "a criterion below its floor blocks even when score and jury both pass" {
103
+ # This is the property that does not exist today: aggregate 0.82 clears the
104
+ # 0.75 threshold, both judges passed, and the gate blocks anyway.
105
+ local out
106
+ out=$(tribunal_gate_decide "majority" '[
107
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}},
108
+ {"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}}
109
+ ]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
110
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
111
+ printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
112
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
113
+ }
114
+
115
+ @test "meta_override accept cannot lift a criterion below its floor" {
116
+ # `accept` returned before the floor check ever ran, so one gate policy
117
+ # defeated every floor in the rubric. A floor is not the jury's opinion for
118
+ # the Meta-Judge to overrule — it is the rubric's hard constraint, and the
119
+ # function's own contract is that it blocks regardless of the policy.
120
+ local meta='{"override_recommendation":"accept","bias_detected":false}'
121
+ local out
122
+ out=$(tribunal_gate_decide "meta_override" '[
123
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}},
124
+ {"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}}
125
+ ]' "0.82" "0.75" "$meta" "0.05" "0.25" "$RUBRIC_FLOOR")
126
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
127
+ printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
128
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
129
+ }
130
+
131
+ @test "meta_override accept still overrules a failing jury when the floors hold" {
132
+ # The veto above must stay narrow: `accept` beating a jury that voted no is
133
+ # the whole purpose of the policy, and only a *violated* floor may stop it.
134
+ local meta='{"override_recommendation":"accept","bias_detected":false}'
135
+ local out
136
+ out=$(tribunal_gate_decide "meta_override" '[
137
+ {"judge_id":"a","score":0.30,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.95}},
138
+ {"judge_id":"b","score":0.40,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.95}}
139
+ ]' "0.30" "0.75" "$meta" "0.10" "0.25" "$RUBRIC_FLOOR")
140
+ printf '%s' "$out" | jq -e '.passed == true' >/dev/null
141
+ }
142
+
143
+ @test "a criterion at exactly its floor passes" {
144
+ local out
145
+ out=$(tribunal_gate_decide "majority" '[
146
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.7,"safety":0.8}},
147
+ {"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.7,"safety":0.8}}
148
+ ]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
149
+ printf '%s' "$out" | jq -e '.passed == true' >/dev/null
150
+ }
151
+
152
+ @test "a verdict with no criterion_scores key at all never blocks" {
153
+ # Every verdict emitted before Task 1 shipped. Treating absence as violation
154
+ # would make every pre-upgrade judge fail every rubric carrying a floor.
155
+ # Caught by the outer `criterion_scores | type == "object"` guard — this
156
+ # case never reaches has(), so it does not pin the has() guard itself.
157
+ local out
158
+ out=$(tribunal_gate_decide "majority" "$ALL_PASSED" "0.82" "0.75" \
159
+ "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
160
+ printf '%s' "$out" | jq -e '.passed == true' >/dev/null
161
+ }
162
+
163
+ @test "scores present but a floored criterion omitted does not block" {
164
+ # THE test that pins the has() guard. These verdicts DO carry
165
+ # criterion_scores, so they survive the outer type guard and reach the
166
+ # per-criterion lookup — but `safety`, whose floor is 0.8, is absent.
167
+ # Substituting `// 0` for has() makes safety read as 0.0 and blocks.
168
+ #
169
+ # Its own test because the case above cannot fail when has() is deleted:
170
+ # that fixture is filtered one layer earlier. Two different absences
171
+ # sharing one test is how an outer guard silently stands in for an inner.
172
+ local out
173
+ out=$(tribunal_gate_decide "majority" '[
174
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9}},
175
+ {"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9}}
176
+ ]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
177
+ printf '%s' "$out" | jq -e '.passed == true' >/dev/null
178
+ }
179
+
180
+ @test "a criterion scored exactly zero does block" {
181
+ # The mirror of the previous test. A fix that conflates absent with zero
182
+ # passes one of these two and fails the other.
183
+ local out
184
+ out=$(tribunal_gate_decide "majority" '[
185
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.0}},
186
+ {"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.0}}
187
+ ]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
188
+ printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
189
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
190
+ }
191
+
192
+ @test "a hyphenated criterion name gates correctly" {
193
+ local out
194
+ out=$(tribunal_gate_decide "majority" '[
195
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"path-traversal":0.1}},
196
+ {"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"path-traversal":0.1}}
197
+ ]' "0.82" "0.75" "$NO_META" "0.05" "0.25" \
198
+ '{"criteria":[{"name":"path-traversal","weight":1.0,"min_pass":0.5}]}')
199
+ printf '%s' "$out" | jq -e '.failed_criterion == "path-traversal"' >/dev/null
200
+ }
201
+
202
+ @test "low_score still wins over criterion_floor" {
203
+ # Precedence matters for the retry digest: if the aggregate missed the
204
+ # threshold, that is the more actionable thing to tell the Actor.
205
+ local out
206
+ out=$(tribunal_gate_decide "majority" '[
207
+ {"judge_id":"a","score":0.20,"passed":true,"criterion_scores":{"correctness":0.1,"safety":0.1}},
208
+ {"judge_id":"b","score":0.20,"passed":true,"criterion_scores":{"correctness":0.1,"safety":0.1}}
209
+ ]' "0.20" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
210
+ printf '%s' "$out" | jq -e '.reason == "low_score"' >/dev/null
211
+ }
212
+
213
+ @test "a floor uses the lowest judge score, not the mean" {
214
+ # A specialist's finding must not be dilutable by generalists who did not
215
+ # look. safety's floor is 0.8; the mean of 0.95/0.95/0.6 is 0.833 and used
216
+ # to pass.
217
+ local out
218
+ out=$(tribunal_gate_decide "majority" '[
219
+ {"judge_id":"a","score":0.9,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.95}},
220
+ {"judge_id":"b","score":0.9,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.95}},
221
+ {"judge_id":"c","score":0.9,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.6}}
222
+ ]' "0.9" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
223
+ printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
224
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
225
+ }
226
+
227
+ @test "a low_score block still names the criterion that failed its floor" {
228
+ local out
229
+ out=$(tribunal_gate_decide "majority" '[
230
+ {"judge_id":"a","score":0.2,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.1}},
231
+ {"judge_id":"b","score":0.2,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.1}}
232
+ ]' "0.20" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
233
+ printf '%s' "$out" | jq -e '.reason == "low_score"' >/dev/null || return 1
234
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
235
+ }
236
+
237
+ @test "a floor on a criterion no judge scored is reported on stderr" {
238
+ # The adversarial-judge gap: safety carries the highest floor and appeared
239
+ # in no agent contract. Silently passing a floor nobody scored is this
240
+ # design's own failure mode one layer down.
241
+ run --separate-stderr tribunal_gate_decide "majority" '[
242
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9}},
243
+ {"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9}}
244
+ ]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR"
245
+ printf '%s' "$output" | jq -e '.passed == true' >/dev/null || return 1
246
+ local re='safety'
247
+ [[ "$stderr" =~ $re ]]
248
+ }
249
+
250
+ @test "no unscored-criterion warning when no judge scored anything" {
251
+ # The pre-upgrade fleet must not spew a warning on every single gate.
252
+ run --separate-stderr tribunal_gate_decide "majority" "$ALL_PASSED" "0.82" "0.75" \
253
+ "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR"
254
+ [ -z "$stderr" ]
255
+ }
256
+
257
+ @test "the gate still works with no rubric at all" {
258
+ local out
259
+ out=$(tribunal_gate_decide "majority" "$ALL_PASSED" "0.82" "0.75" "$NO_META" "0.05" "0.25")
260
+ printf '%s' "$out" | jq -e '.passed == true' >/dev/null
261
+ }
262
+
263
+ # --- Verdicts the aggregate refuses to score (ecosystem-y7y) ----------------
264
+ #
265
+ # tribunal_aggregate drops a verdict with no numeric .score (ecosystem-up8),
266
+ # but the jury counted .passed over the raw panel, so a verdict that
267
+ # contributed nothing to the aggregate could still vote.
268
+ #
269
+ # The fix denies the vote and KEEPS THE SEAT. Dropping the verdict outright —
270
+ # making both halves agree on membership, which is what this bead originally
271
+ # asked for — shrinks the panel, and a shrunken panel is a trivially satisfied
272
+ # majority. That is the hazard librarian's judge-type check exists to prevent,
273
+ # so the naive reading of "agree on membership" would have loosened the gate
274
+ # in three cases while fixing one. A judge that failed to return a verdict did
275
+ # not leave the panel; it failed.
276
+
277
+ @test "a verdict with no usable score cannot cast a passing vote" {
278
+ # The verified fail-open: the only judge that actually returned a verdict
279
+ # rejected the work, and two malformed verdicts supplied the majority.
280
+ #
281
+ # The fixture is asserted well-formed first. An earlier draft of this test
282
+ # built the panel by string-concatenating a fixture variable and produced
283
+ # invalid JSON, which made jq fail and passed_count fall back to 0 — so it
284
+ # passed while proving nothing, whether or not the guard existed.
285
+ local panel='[
286
+ {"judge_id":"real","score":0.9,"passed":false},
287
+ {"judge_id":"bad1","passed":true},
288
+ {"judge_id":"bad2","passed":true}
289
+ ]'
290
+ printf '%s' "$panel" | jq -e 'length == 3' >/dev/null || return 1
291
+
292
+ local out
293
+ out=$(tribunal_gate_decide "majority" "$panel" "0.90" "0.75" "$NO_META" "0.05" "0.25")
294
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null
295
+ }
296
+
297
+ @test "a malformed verdict keeps its seat, so a thin panel is not a trivial majority" {
298
+ # One real approval plus two verdicts that never arrived is not a majority
299
+ # of a three-judge panel. Filtering them out would make it one.
300
+ local out
301
+ out=$(tribunal_gate_decide "majority" '[
302
+ {"judge_id":"real","score":0.9,"passed":true},
303
+ {"judge_id":"bad1","passed":false},
304
+ {"judge_id":"bad2","passed":false}
305
+ ]' "0.90" "0.75" "$NO_META" "0.05" "0.25")
306
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null
307
+ }
308
+
309
+ @test "strict blocks when any judge returned no usable verdict" {
310
+ # strict means every judge passed. A judge that returned nothing did not.
311
+ local out
312
+ out=$(tribunal_gate_decide "strict" '[
313
+ {"judge_id":"a","score":0.9,"passed":true},
314
+ {"judge_id":"b","score":0.8,"passed":true},
315
+ {"judge_id":"bad","passed":true}
316
+ ]' "0.85" "0.75" "$NO_META" "0.05" "0.25")
317
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null
318
+ }
319
+
320
+ @test "a malformed verdict does not block a genuine majority" {
321
+ # The guard must not overreach: two real approvals out of three still carry
322
+ # a majority gate, exactly as before.
323
+ local out
324
+ out=$(tribunal_gate_decide "majority" '[
325
+ {"judge_id":"a","score":0.9,"passed":true},
326
+ {"judge_id":"b","score":0.8,"passed":true},
327
+ {"judge_id":"bad","passed":true}
328
+ ]' "0.85" "0.75" "$NO_META" "0.05" "0.25")
329
+ printf '%s' "$out" | jq -e '.passed == true' >/dev/null
330
+ }
331
+
332
+ @test "usable verdicts agree with tribunal_aggregate" {
333
+ # The gate and tribunal_aggregate each carry their own copy of "is this
334
+ # verdict usable", in different files, with nothing keeping them in step.
335
+ # This pins the equivalence behaviorally rather than by comparing source.
336
+ #
337
+ # A single-judge panel makes both answers observable: majority needs one
338
+ # passing vote, and the aggregate of a lone unusable verdict is 0.
339
+ # shellcheck disable=SC1091
340
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-aggregate.sh"
341
+
342
+ local shape agg out kept votable
343
+ for shape in \
344
+ '{"judge_id":"a","score":0.9,"passed":true}' \
345
+ '{"judge_id":"a","passed":true}' \
346
+ '{"judge_id":"a","score":"high","passed":true}' \
347
+ '{"judge_id":"a","score":null,"passed":true}' \
348
+ '{"judge_id":"a","score":100,"passed":true}' \
349
+ '{"judge_id":"a","score":-5,"passed":true}' \
350
+ '{"judge_id":"a","score":1,"passed":true}'
351
+ do
352
+ # Does the aggregate keep it? A kept verdict yields its own score.
353
+ agg=$(tribunal_aggregate "mean" "[$shape]" 2>/dev/null)
354
+ kept=$(awk -v a="$agg" 'BEGIN { print (a > 0.5) ? "yes" : "no" }')
355
+
356
+ # Does the gate let it vote? Handed an aggregate that clears the
357
+ # threshold, a one-judge majority turns entirely on the vote.
358
+ out=$(tribunal_gate_decide "majority" "[$shape]" "0.90" "0.75" \
359
+ "$NO_META" "0.0" "0.25" 2>/dev/null)
360
+ votable=$(printf '%s' "$out" | jq -r 'if .passed then "yes" else "no" end')
361
+
362
+ [ "$kept" = "$votable" ] || {
363
+ printf 'drift on %s: aggregate kept=%s, gate votable=%s\n' \
364
+ "$shape" "$kept" "$votable" >&2
365
+ return 1
366
+ }
367
+ done
368
+ }
369
+
370
+ # --- out-of-range verdict scores (ecosystem-7cl) --------------------------
371
+
372
+ @test "a whole panel of out-of-range scores cannot pass the gate" {
373
+ # Three judges all reporting 95-instead-of-0.95. Pre-fix the aggregate
374
+ # trusted them, so every threshold cleared. The scores are deliberately
375
+ # IDENTICAL: a spread of out-of-range scores blocks on dissent instead, so
376
+ # the mixed panel would pass this test with the bug still in place. Only a
377
+ # unanimous out-of-range panel — dissent 0, nothing left to catch it — is
378
+ # the actual fail-open.
379
+ # shellcheck disable=SC1091
380
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-aggregate.sh"
381
+
382
+ local panel='[{"judge_id":"a","score":95,"passed":true},{"judge_id":"b","score":95,"passed":true},{"judge_id":"c","score":95,"passed":true}]'
383
+ local agg dissent out
384
+ agg=$(tribunal_aggregate "mean" "$panel" 2>/dev/null)
385
+ dissent=$(tribunal_disagreement "$panel" 2>/dev/null)
386
+ out=$(tribunal_gate_decide "majority" "$panel" "$agg" "0.75" "$NO_META" "$dissent" "0.25" 2>/dev/null)
387
+
388
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ]
389
+ }
390
+
391
+ @test "an out-of-range verdict keeps its seat, so it cannot be voted around" {
392
+ # Same rule y7y settled for scoreless verdicts: a judge whose verdict was
393
+ # refused did not leave the panel, it failed. Two real approvals beside one
394
+ # out-of-range verdict is 2-of-3, not 2-of-2.
395
+ local panel='[{"judge_id":"a","score":0.9,"passed":true},{"judge_id":"b","score":0.85,"passed":true},{"judge_id":"c","score":100,"passed":true}]'
396
+ local out
397
+ out=$(tribunal_gate_decide "strict" "$panel" "0.87" "0.75" "$NO_META" "0.05" "0.25" 2>/dev/null)
398
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "false" ] || return 1
399
+
400
+ # ...and majority, which two of three genuinely satisfies, still passes.
401
+ out=$(tribunal_gate_decide "majority" "$panel" "0.87" "0.75" "$NO_META" "0.05" "0.25" 2>/dev/null)
402
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "true" ]
403
+ }
404
+
405
+ @test "a verdict scored 0 still casts its vote" {
406
+ # [0,1] is inclusive at both ends. 0 is a real verdict — the judge scored
407
+ # the work and scored it badly — so it must not be swept up with the
408
+ # malformed ones. Pinned at the gate because the drift-guard loop below
409
+ # infers usability from the aggregate clearing 0.5 and cannot see this.
410
+ local out
411
+ out=$(tribunal_gate_decide "strict" \
412
+ '[{"judge_id":"a","score":0,"passed":true}]' "0.90" "0.75" \
413
+ "$NO_META" "0.0" "0.25" 2>/dev/null)
414
+ [ "$(printf '%s' "$out" | jq -r '.passed')" = "true" ]
415
+ }
416
+
417
+ # --- out-of-range criterion scores (ecosystem-5fy) ------------------------
418
+ #
419
+ # A floor is the rubric's hard constraint: nobody may be below it. An
420
+ # unreadable report on a floored criterion means we cannot confirm that held,
421
+ # and "cannot confirm" on a hard constraint has to fail closed.
422
+
423
+ @test "an out-of-range criterion score cannot satisfy its floor" {
424
+ # THE verified case. safety 0.30 against an 0.8 floor blocks; the identical
425
+ # judgment written as 30 used to pass clean, because 30 < 0.8 is false.
426
+ local out
427
+ out=$(tribunal_gate_decide "majority" '[
428
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}},
429
+ {"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}}
430
+ ]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
431
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
432
+ printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
433
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
434
+ }
435
+
436
+ @test "one judge's unreadable floor score is not diluted by judges who scored it fine" {
437
+ # The dilution failure the floor design exists to prevent, reproduced
438
+ # through a different mechanism. A security specialist emitting 30 — which
439
+ # may well mean 0.30, a violation — must not be silently discarded so that
440
+ # two generalists at 0.95 carry the floor. Filtering the bad value alone
441
+ # gives min([0.95,0.95]) and passes, which is why filtering is not enough.
442
+ local out
443
+ out=$(tribunal_gate_decide "majority" '[
444
+ {"judge_id":"sec","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}},
445
+ {"judge_id":"gen1","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.95}},
446
+ {"judge_id":"gen2","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.95}}
447
+ ]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
448
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
449
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
450
+ }
451
+
452
+ @test "a non-numeric criterion score cannot satisfy its floor either" {
453
+ # Unreadable is unreadable — a string is no more confirmable than a 30.
454
+ local out
455
+ out=$(tribunal_gate_decide "majority" '[
456
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":"high"}},
457
+ {"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":"high"}}
458
+ ]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
459
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
460
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
461
+ }
462
+
463
+ @test "an unreadable floor score is not reported as one nobody scored" {
464
+ # The two are different and must not be conflated. The stderr warning says
465
+ # the floor DID NOT APPLY — true when the criterion is absent, and a false
466
+ # statement here, where it applied and blocked. Conflating them also means
467
+ # the one signal for "a floor was skipped" is emitted by the same defect
468
+ # that skipped it.
469
+ run --separate-stderr tribunal_gate_decide "majority" '[
470
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}},
471
+ {"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}}
472
+ ]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR"
473
+ printf '%s' "$output" | jq -e '.passed == false' >/dev/null || return 1
474
+ local re='did not apply'
475
+ [[ ! "$stderr" =~ $re ]]
476
+ }
477
+
478
+ @test "meta_override accept cannot lift a floor it cannot confirm" {
479
+ # The accept-veto added in #152 exists because a floor is not the jury's
480
+ # opinion to overrule. An unconfirmable floor must sit behind the same veto,
481
+ # or the policy becomes a way to launder a malformed score into a pass.
482
+ local meta='{"override_recommendation":"accept","bias_detected":false}'
483
+ local out
484
+ out=$(tribunal_gate_decide "meta_override" '[
485
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}},
486
+ {"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":30}}
487
+ ]' "0.85" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
488
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
489
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
490
+ }
491
+
492
+ @test "the range bounds stay usable for criterion scores too" {
493
+ # [0,1] inclusive. A criterion scored exactly 0 already has its own test
494
+ # above (it blocks); this pins the other end, where 1.0 must read as a
495
+ # perfect score rather than as out-of-range garbage.
496
+ local out
497
+ out=$(tribunal_gate_decide "majority" '[
498
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":1,"safety":1}},
499
+ {"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":1,"safety":1}}
500
+ ]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
501
+ printf '%s' "$out" | jq -e '.passed == true' >/dev/null
502
+ }
503
+
504
+ @test "readable criterion scores agree with tribunal_aggregate" {
505
+ # The criterion-level twin of "usable verdicts agree with tribunal_aggregate".
506
+ # weighted_mean's filter and _tribunal_floor_failed's `readable` live in
507
+ # different files with nothing keeping them in step, and them disagreeing IS
508
+ # ecosystem-5fy: aggregation refused to score 30 while the floor accepted it.
509
+ #
510
+ # Every shape below sits at or above the 0.7 floor when readable, so the
511
+ # gate blocks on unreadability alone and the two answers are comparable:
512
+ # a criterion the aggregate USED is exactly one the gate did not reject.
513
+ # shellcheck disable=SC1091
514
+ source "${PLUGIN_ROOT}/scripts/lib/tribunal-aggregate.sh"
515
+
516
+ local rubric='{"criteria":[{"name":"correctness","weight":1.0,"min_pass":0.7}]}'
517
+ local shape agg out used blocked
518
+ for shape in '0.9' '1' '0.7' '100' '-1' '"high"' 'null'; do
519
+ local panel
520
+ panel="[{\"judge_id\":\"a\",\"score\":0.5,\"passed\":true,\"criterion_scores\":{\"correctness\":${shape}}}]"
521
+
522
+ # Did the aggregate use the criterion? If it did, the result is the
523
+ # criterion's own value; if it refused, weighted_mean degrades to the
524
+ # plain mean of .score, which is 0.5.
525
+ agg=$(tribunal_aggregate "weighted_mean" "$panel" "$rubric" 2>/dev/null)
526
+ used=$(awk -v a="$agg" 'BEGIN { print (a == 0.5) ? "no" : "yes" }')
527
+
528
+ # Did the gate reject it as unreadable? Handed an aggregate that clears
529
+ # the threshold, only the floor check can block.
530
+ out=$(tribunal_gate_decide "majority" "$panel" "0.90" "0.75" \
531
+ "$NO_META" "0.0" "0.25" "$rubric" 2>/dev/null)
532
+ blocked=$(printf '%s' "$out" | jq -r 'if .passed then "no" else "yes" end')
533
+
534
+ [ "$used" != "$blocked" ] || {
535
+ printf 'drift on correctness=%s: aggregate used=%s, gate blocked=%s\n' \
536
+ "$shape" "$used" "$blocked" >&2
537
+ return 1
538
+ }
539
+ done
540
+ }
541
+
542
+ # --- blocking-reason precedence (ecosystem-4d3) ---------------------------
543
+ #
544
+ # Nothing pinned the order of these arms, so it could be reshuffled silently —
545
+ # which is how criterion_floor came to sit ahead of the jury arms in #150 under
546
+ # a comment claiming precedence was unchanged. Librarian's sibling gate already
547
+ # settled this the other way ("lesson gate: jury policy still wins over
548
+ # criterion_floor"); tribunal now matches.
549
+ #
550
+ # The jury arms report WHY the panel failed. A floor still blocks, and the
551
+ # meta_override accept-veto still makes it unoverridable — those are about the
552
+ # OUTCOME. This chain only picks which reason to name, and a Meta-Judge
553
+ # rejection is the more actionable thing to hand the Actor on retry than which
554
+ # criterion sat low. The floor rides along as failed_criterion either way.
555
+
556
+ @test "a Meta-Judge rejection outranks a violated floor as the reason" {
557
+ # The bead's verified case. Both block; the question is what the Actor is
558
+ # told. Pre-fix this reported criterion_floor and the rejection vanished.
559
+ local meta='{"override_recommendation":"reject","bias_detected":false}'
560
+ local out
561
+ out=$(tribunal_gate_decide "majority" '[
562
+ {"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
563
+ {"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
564
+ ]' "0.85" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
565
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
566
+ printf '%s' "$out" | jq -e '.reason == "meta_override"' >/dev/null || return 1
567
+ # ...and the floor is not lost, it decorates. This is ecosystem-cs8: the
568
+ # suffix on this arm was unreachable dead code before the reorder.
569
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
570
+ }
571
+
572
+ @test "an unresolved jury outranks a violated floor as the reason" {
573
+ # The other jury arm, reached when no override was given and dissent sits
574
+ # below threshold. Same rule, and the same previously-dead suffix.
575
+ local out
576
+ out=$(tribunal_gate_decide "majority" '[
577
+ {"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
578
+ {"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
579
+ ]' "0.85" "0.75" "$NO_META" "0.0" "0.25" "$RUBRIC_FLOOR")
580
+ printf '%s' "$out" | jq -e '.passed == false' >/dev/null || return 1
581
+ printf '%s' "$out" | jq -e '.reason == "dissent_unresolved"' >/dev/null || return 1
582
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
583
+ }
584
+
585
+ @test "criterion_floor is still the reason when the jury has no complaint" {
586
+ # Demoting the arm must not make it unreachable. A floor violated while the
587
+ # score clears and every judge passed is exactly the case criterion_floor
588
+ # exists for, and it is the whole point of the rubric's hard constraint.
589
+ local out
590
+ out=$(tribunal_gate_decide "majority" '[
591
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}},
592
+ {"judge_id":"b","score":0.80,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}}
593
+ ]' "0.82" "0.75" "$NO_META" "0.05" "0.25" "$RUBRIC_FLOOR")
594
+ printf '%s' "$out" | jq -e '.reason == "criterion_floor"' >/dev/null || return 1
595
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
596
+ }
597
+
598
+ @test "low_score still outranks both, and still names the floor" {
599
+ # The one arm whose position is NOT changing. Pinned so the reorder cannot
600
+ # quietly take it along.
601
+ local meta='{"override_recommendation":"reject","bias_detected":false}'
602
+ local out
603
+ out=$(tribunal_gate_decide "majority" '[
604
+ {"judge_id":"a","score":0.30,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
605
+ {"judge_id":"b","score":0.30,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
606
+ ]' "0.30" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
607
+ printf '%s' "$out" | jq -e '.reason == "low_score"' >/dev/null || return 1
608
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
609
+ }
610
+
611
+ # --- failed_criterion on the short-circuit arms (ecosystem-973) -----------
612
+ #
613
+ # floor_failed is computed near the top of the function, but floor_suffix was
614
+ # not built until well below these three early returns, so they could not
615
+ # reference it however much they wanted to. The result was two behaviors behind
616
+ # one reason string: dissent_unresolved carried the criterion from the final
617
+ # chain and dropped it from the short-circuit, which is the part most likely to
618
+ # mislead someone reading gate.blocked events.
619
+ #
620
+ # A missing diagnostic, never a wrong verdict — none of these change whether
621
+ # the gate blocks, only what it says about why.
622
+
623
+ @test "meta_override reject names a violated floor" {
624
+ local meta='{"override_recommendation":"reject","bias_detected":false}'
625
+ local out
626
+ out=$(tribunal_gate_decide "meta_override" '[
627
+ {"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
628
+ {"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
629
+ ]' "0.85" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
630
+ printf '%s' "$out" | jq -e '.reason == "meta_override"' >/dev/null || return 1
631
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
632
+ }
633
+
634
+ @test "bias_detected names a violated floor" {
635
+ local meta='{"override_recommendation":"reject","bias_detected":true,"bias_types":["verbosity"]}'
636
+ local out
637
+ out=$(tribunal_gate_decide "majority" '[
638
+ {"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
639
+ {"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
640
+ ]' "0.85" "0.75" "$meta" "0.0" "0.25" "$RUBRIC_FLOOR")
641
+ printf '%s' "$out" | jq -e '.reason == "bias_detected"' >/dev/null || return 1
642
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
643
+ }
644
+
645
+ @test "the dissent short-circuit names a violated floor, like its chain twin" {
646
+ # THE inconsistency this bead is really about: the same reason string
647
+ # behaving two ways depending on which return produced it.
648
+ local out
649
+ out=$(tribunal_gate_decide "majority" '[
650
+ {"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
651
+ {"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
652
+ ]' "0.85" "0.75" "$NO_META" "0.90" "0.25" "$RUBRIC_FLOOR")
653
+ printf '%s' "$out" | jq -e '.reason == "dissent_unresolved"' >/dev/null || return 1
654
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null
655
+ }
656
+
657
+ @test "the short-circuit arms stay quiet when no floor was violated" {
658
+ # The suffix must remain a decoration, not become noise. Absent is still the
659
+ # right answer when there is nothing to name — and with the arms now
660
+ # reporting it, an absent field finally means what it says.
661
+ local out
662
+ local clean='[
663
+ {"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.95}},
664
+ {"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.95}}
665
+ ]'
666
+ out=$(tribunal_gate_decide "meta_override" "$clean" "0.85" "0.75" \
667
+ '{"override_recommendation":"reject","bias_detected":false}' "0.0" "0.25" "$RUBRIC_FLOOR")
668
+ printf '%s' "$out" | jq -e 'has("failed_criterion") | not' >/dev/null || return 1
669
+
670
+ out=$(tribunal_gate_decide "majority" "$clean" "0.85" "0.75" \
671
+ '{"override_recommendation":"reject","bias_detected":true}' "0.0" "0.25" "$RUBRIC_FLOOR")
672
+ printf '%s' "$out" | jq -e 'has("failed_criterion") | not' >/dev/null || return 1
673
+
674
+ out=$(tribunal_gate_decide "majority" "$clean" "0.85" "0.75" \
675
+ "$NO_META" "0.90" "0.25" "$RUBRIC_FLOOR")
676
+ printf '%s' "$out" | jq -e 'has("failed_criterion") | not' >/dev/null
677
+ }
678
+
679
+ @test "every blocking arm that can see a violated floor names it" {
680
+ # The property the individual tests add up to, asserted as one thing so a
681
+ # newly-added arm that forgets the suffix is caught by an existing test
682
+ # rather than needing someone to remember to write a new one.
683
+ local panel='[
684
+ {"judge_id":"a","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}},
685
+ {"judge_id":"b","score":0.85,"passed":false,"criterion_scores":{"correctness":0.9,"safety":0.3}}
686
+ ]'
687
+ local passing='[
688
+ {"judge_id":"a","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}},
689
+ {"judge_id":"b","score":0.85,"passed":true,"criterion_scores":{"correctness":0.9,"safety":0.3}}
690
+ ]'
691
+ local reject='{"override_recommendation":"reject","bias_detected":false}'
692
+ local biased='{"override_recommendation":"reject","bias_detected":true}'
693
+
694
+ # Args are passed positionally rather than packed into a delimited string.
695
+ # An earlier version packed them and unpacked with eval, which stripped the
696
+ # JSON's own quotes: meta parsed as empty, the meta_override policy fell
697
+ # through to the chain, and two arms reported PASS without ever reaching the
698
+ # return they were meant to exercise.
699
+ _arm_names_floor() {
700
+ local label="$1" policy="$2" verdicts="$3" agg="$4" meta="$5" dissent="$6"
701
+ local out reason
702
+ out=$(tribunal_gate_decide "$policy" "$verdicts" "$agg" "0.75" \
703
+ "$meta" "$dissent" "0.25" "$RUBRIC_FLOOR")
704
+ reason=$(printf '%s' "$out" | jq -r '.reason // "none"')
705
+ # Guard the premise too: an arm that silently stopped being reachable
706
+ # would otherwise "pass" this by never being the arm under test.
707
+ [ "$reason" = "$label" ] || {
708
+ printf 'expected reason %s, got %s: %s\n' "$label" "$reason" "$out" >&2
709
+ return 1
710
+ }
711
+ printf '%s' "$out" | jq -e '.failed_criterion == "safety"' >/dev/null || {
712
+ printf 'arm %s dropped failed_criterion: %s\n' "$reason" "$out" >&2
713
+ return 1
714
+ }
715
+ }
716
+
717
+ # final chain
718
+ _arm_names_floor low_score majority "$panel" 0.30 "$NO_META" 0.0 || return 1
719
+ _arm_names_floor meta_override majority "$panel" 0.85 "$reject" 0.0 || return 1
720
+ _arm_names_floor dissent_unresolved majority "$panel" 0.85 "$NO_META" 0.0 || return 1
721
+ _arm_names_floor criterion_floor majority "$passing" 0.85 "$NO_META" 0.0 || return 1
722
+ # short-circuit returns
723
+ _arm_names_floor meta_override meta_override "$panel" 0.85 "$reject" 0.0 || return 1
724
+ _arm_names_floor bias_detected majority "$panel" 0.85 "$biased" 0.0 || return 1
725
+ _arm_names_floor dissent_unresolved majority "$panel" 0.85 "$NO_META" 0.90
726
+ }