bareloop 0.11.0 → 0.12.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.
package/src/authorjob.js CHANGED
@@ -91,11 +91,16 @@
91
91
  // in the stored declaration (forward-compat point 3), and the suite pins it.
92
92
 
93
93
  import { jobSpecHash, validateJob } from './job.js';
94
- import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees } from './kinds.js';
94
+ import { seedAtHead, seedListing, seedRead as runSeedRead, makeSeedTrees, SEED_EXEMPT_KINDS, JUDGED_FLOOR_KIND } from './kinds.js';
95
+ // SOFTGREEN modules 4 and 5 — the compile the interview's Q6/Q7 answers land in,
96
+ // and the gate that decides whether the ruler they describe is a ruler.
97
+ import { proposeJudgedArtifacts, signJudgedArtifacts, foldJudgedArtifacts } from './cardauthor.js';
98
+ import { runCalibration } from './calibrate.js';
99
+ import { JUDGE_MODEL, CALIBRATION_SIZE } from './judged.js';
95
100
  import {
96
101
  GENRE_LANGUAGES, LOCKED_KINDS, TYPES_GENRE, VERDICT_CLASSES, LOCKED_CLASSES, LIVE_CLASSES,
97
102
  } from './authoring.js';
98
- import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose } from './authorflow.js';
103
+ import { QUESTION_SETS, questionsFor, requiredAnswersFor, authorClose, makeCostBook } from './authorflow.js';
99
104
  import { runAuthorScout, buildSeedListing, SCOUT_ATTEMPTS } from './authorscout.js';
100
105
  import {
101
106
  DECLARED_GAP_PREFIX, DECLARED_GENRES, guardNames, isDeclaredClose, validateCloseDecl,
@@ -369,10 +374,11 @@ function composerRefusal(reds) {
369
374
  * onPhase?: (phase: string, data?: any) => void,
370
375
  * onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number}) => void,
371
376
  * seedFn?: Function, scoutFn?: Function, listingFn?: Function,
372
- * authorFn?: Function, authorOpts?: object}} o
377
+ * authorFn?: Function, authorOpts?: object,
378
+ * signerFix?: Function|any, proposeFn?: Function, compileOpts?: object}} o
373
379
  * @returns {Promise<{ok: boolean, refusal: Refusal|null, verdictType: string|null,
374
- * closeDecl: any, seedRef: string|null, authoring: any, reds: Red[], stop: string|null,
375
- * cost: any, interview: any}>}
380
+ * closeDecl: any, seedRef: string|null, authoring: any, judged: any, reds: Red[],
381
+ * stop: string|null, cost: any, interview: any}>}
376
382
  */
377
383
  export async function authorCloseForJob({
378
384
  answers, repoPath = null, lang, verdictType = null, questions = null,
@@ -387,11 +393,16 @@ export async function authorCloseForJob({
387
393
  onPhase = () => {}, onCall = () => {},
388
394
  seedFn = seedAtHead, scoutFn = runAuthorScout, listingFn = buildSeedListing,
389
395
  authorFn = authorClose, authorOpts = {},
396
+ // SOFTGREEN modules 4+5. `signerFix` is the D5 seam: a function (or a value)
397
+ // carrying the SIGNER's corrected artifacts back. Absent means the proposal is
398
+ // signed as proposed, and `signJudgedArtifacts` records which of the two
399
+ // happened rather than leaving a reader to guess.
400
+ signerFix = null, proposeFn = proposeJudgedArtifacts, compileOpts = {},
390
401
  }) {
391
402
  /** @type {any} */
392
403
  const base = {
393
404
  ok: false, refusal: null, verdictType: null, closeDecl: null, seedRef: null,
394
- authoring: null, reds: [], stop: null, cost: null, interview: null,
405
+ authoring: null, judged: null, reds: [], stop: null, cost: null, interview: null,
395
406
  };
396
407
 
397
408
  const interview = runInterview({ answers, verdictType, repoPath, questions });
@@ -528,29 +539,111 @@ export async function authorCloseForJob({
528
539
  // declaration above it), and stated here rather than re-derived. The remaining
529
540
  // precondition (D9.3 — the close has work to do) is measured by
530
541
  // `prepareSigning`, which refuses there.
542
+ /** @type {any} */
543
+ let closeDecl = {
544
+ genre: GENRE,
545
+ lang,
546
+ stages: authored.declaration.stages,
547
+ ...(Object.keys(applied).length ? { genreEnv: { ...applied } } : {}),
548
+ // `notes` is the one FREE-TEXT field the model writes straight into the
549
+ // signed spec, and this is that field's persist boundary. Scrubbed here
550
+ // for the same reason `scrubRed` scrubs uniformly a few lines down: every
551
+ // other model-authored string that survives this call rides the ONE
552
+ // redactor, and a signed spec outlives the run that produced it.
553
+ // `redactSecrets` returns a non-matching string byte-identical, so a note
554
+ // carrying nothing hashes exactly as it did — the mask costs the honest
555
+ // case nothing, which is the fail-safe direction.
556
+ ...(authored.declaration.notes?.length
557
+ ? { notes: authored.declaration.notes.map((/** @type {any} */ n) => (typeof n === 'string' ? redactSecrets(n) : n)) }
558
+ : {}),
559
+ };
560
+
561
+ // ── THE TWO SIGNED ARTIFACTS (softgreen module 4, wired here by module 5) ──
562
+ //
563
+ // A close that judges needs a rubric card and a calibration set, and until now
564
+ // nothing in the pipeline called the compile — module 4 shipped the machinery
565
+ // and said so. This is the call: Q6 and Q7 go to the compiler VERBATIM, the
566
+ // proposal is SHOWN (`onPhase`, and the driver prints it), the SIGNER's fix is
567
+ // what is stored, and both land inside the spec where the hash covers them.
568
+ //
569
+ // KEYED OFF THE DECLARATION, never off the picked class: a soft-green close
570
+ // that composed no judged stage needs no card, and a judged stage is a judged
571
+ // stage whatever the radio said. One judged stage is the composition law
572
+ // (AT_MOST_ONCE_KINDS), so the fold's own throw is unreachable here — it stays
573
+ // as the caller-error guard it is.
574
+ /** @type {any} */
575
+ let judged = null;
576
+ if ((closeDecl.stages ?? []).some((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND)) {
577
+ // ONE CEILING, ALL THREE PAID SEAMS. The declaration loop's own calls are
578
+ // ABSORBED before the compile is asked for, so the ceiling folds in prior
579
+ // spend and re-invoking a seam cannot silently widen it. `absorb` deliberately
580
+ // does not re-fire `onCall`, so nothing is reported twice.
581
+ const book = makeCostBook({ ceilingUsd, onCall });
582
+ book.absorb(authored.cost?.calls ?? []);
583
+ onPhase('rubric', { size: CALIBRATION_SIZE });
584
+ const proposed = await proposeFn({
585
+ answers: interview.answers,
586
+ questions: questions ?? questionsFor(picked),
587
+ generate,
588
+ book,
589
+ ...compileOpts,
590
+ });
591
+ onPhase('rubric-done', { ok: proposed.ok, stop: proposed.stop, attempts: proposed.attempts });
592
+ if (!proposed.ok) {
593
+ return {
594
+ ...base,
595
+ verdictType: picked, seedRef: seed, authoring: authored, judged: { proposal: proposed, signed: null },
596
+ reds: proposed.reds, stop: proposed.stop, cost: book.report(), interview,
597
+ };
598
+ }
599
+
600
+ // THE D5 MOMENT. `signerFix` is the seam a UI (or an operator's own editor)
601
+ // hands the fixed artifacts back through; ABSENT is a legal answer and it is
602
+ // recorded as one — `source: 'proposal'` says *nobody changed this*, which is
603
+ // a different fact from *a person read it and changed nothing*.
604
+ const fix = typeof signerFix === 'function' ? await signerFix(proposed.proposal) : signerFix;
605
+ const signed = signJudgedArtifacts({ proposal: proposed.proposal, fix });
606
+ judged = { proposal: proposed, signed, scrubbed: signed.scrubbed, source: signed.source };
607
+ if (!signed.ok) {
608
+ return {
609
+ ...base,
610
+ verdictType: picked, seedRef: seed, authoring: authored, judged,
611
+ reds: signed.reds, stop: 'rubric-invalid', cost: book.report(), interview,
612
+ };
613
+ }
614
+ // THE SCRUB, ANNOUNCED. A signer signs a hash over the STORED bytes, so bytes
615
+ // the ONE inventory masked on the way in are said out loud rather than left
616
+ // for them to notice — by PATH only, because echoing the matched string would
617
+ // be the same leak one hop on.
618
+ if (signed.scrubbed.length) onPhase('rubric-scrubbed', { paths: signed.scrubbed.map((s) => s.path) });
619
+ // `cases` is an array on every ok path — `validateCalibrationSet` reds a
620
+ // non-array before anything can be `ok` — and the narrowing is stated rather
621
+ // than defaulted: `?? []` here would fold an EMPTY signed set into a close,
622
+ // which is the one direction that must never be reachable.
623
+ closeDecl = foldJudgedArtifacts(closeDecl, { card: signed.card, cases: /** @type {any[]} */ (signed.cases) });
624
+ return {
625
+ ok: true,
626
+ refusal: null,
627
+ verdictType: picked,
628
+ closeDecl,
629
+ seedRef: seed,
630
+ authoring: authored,
631
+ judged,
632
+ reds: authored.reds,
633
+ stop: authored.stop,
634
+ cost: book.report(),
635
+ interview,
636
+ };
637
+ }
638
+
531
639
  return {
532
640
  ok: true,
533
641
  refusal: null,
534
642
  verdictType: picked,
535
- closeDecl: {
536
- genre: GENRE,
537
- lang,
538
- stages: authored.declaration.stages,
539
- ...(Object.keys(applied).length ? { genreEnv: { ...applied } } : {}),
540
- // `notes` is the one FREE-TEXT field the model writes straight into the
541
- // signed spec, and this is that field's persist boundary. Scrubbed here
542
- // for the same reason `scrubRed` scrubs uniformly a few lines down: every
543
- // other model-authored string that survives this call rides the ONE
544
- // redactor, and a signed spec outlives the run that produced it.
545
- // `redactSecrets` returns a non-matching string byte-identical, so a note
546
- // carrying nothing hashes exactly as it did — the mask costs the honest
547
- // case nothing, which is the fail-safe direction.
548
- ...(authored.declaration.notes?.length
549
- ? { notes: authored.declaration.notes.map((/** @type {any} */ n) => (typeof n === 'string' ? redactSecrets(n) : n)) }
550
- : {}),
551
- },
643
+ closeDecl,
552
644
  seedRef: seed,
553
645
  authoring: authored,
646
+ judged,
554
647
  reds: authored.reds,
555
648
  stop: authored.stop,
556
649
  cost: authored.cost,
@@ -631,6 +724,166 @@ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
631
724
  Object.entries(r).map(([k, v]) => [k, typeof v === 'string' ? redactSecrets(v) : v]),
632
725
  ));
633
726
 
727
+ /**
728
+ * GATE 4 — the calibration gate, and the only gate here that spends money.
729
+ *
730
+ * It answers ONE question: has this close's own ruler been shown to grade the
731
+ * signer's own ten correctly, itemized, and to resist all five attack styles?
732
+ * Anything else it learns — a missing set, an unwired judge, a dead call — is
733
+ * reported under its own name and refuses, because every one of them leaves the
734
+ * question unanswered and an unanswered calibration is not a passed one.
735
+ *
736
+ * THE CASUALTY SPLIT is the same one the judged stage runs (F45, contract (a)): a
737
+ * provider failure, an unpriced call or an unparseable emission after the ladder
738
+ * says NOTHING about the set. It refuses the signature — an ungraded ruler is
739
+ * still ungraded — but it refuses under the TRANSPORT's name, so the operator is
740
+ * sent to the socket or the meter and never to their own rubric.
741
+ * AND IT MEETS THE ONE CEILING. This is the run's THIRD paid seam — the largest of
742
+ * them, at up to `CALIBRATION_SIZE` locate calls plus a five-artifact battery, each
743
+ * under its own retry ladder — and it used to run with no bound at all while the
744
+ * scout and the declaration loop were both stopped by the operator's own number.
745
+ * That is the advertised budget and the enforced budget being two different numbers.
746
+ * The book is built HERE and PRIOR SPEND IS ABSORBED into it, so the ceiling folds in
747
+ * what the earlier seams already spent and re-invoking a seam cannot silently widen
748
+ * it; the gate reads it between calls and stops as a refusal that names money.
749
+ * @param {{spec: any, judgedStages: any[], judgeLoop: Function|null,
750
+ * onJudgeCost: ((c: any) => void)|null, calibrateFn: Function,
751
+ * ceilingUsd: number|null, priorCalls: any[]}} o
752
+ * @returns {Promise<{ok: boolean, record: any, reds: Red[], refusal: Refusal|null}>}
753
+ */
754
+ async function calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, calibrateFn, ceilingUsd, priorCalls }) {
755
+ const cases = spec.closeDecl?.calibration?.cases ?? null;
756
+ // ONE card, and it is the SIGNED one — the card the calibration is graded
757
+ // against must be the card the close will RUN, or the gate certifies a ruler
758
+ // nobody is going to use. `validateCloseDecl` has already refused a second
759
+ // judged stage (AT_MOST_ONCE_KINDS), so there is exactly one here.
760
+ const card = judgedStages[0]?.params?.card ?? null;
761
+
762
+ if (cases === null || cases === undefined) {
763
+ const detail = `This close judges with a rubric card, and no calibration set is stored with it. The judged floor `
764
+ + `is only a floor once the whole pipe has graded ${CALIBRATION_SIZE} signed cases correctly — until then the `
765
+ + 'ruler is unmeasured, and an unmeasured ruler cannot be signed.';
766
+ return {
767
+ ok: false,
768
+ record: { ok: false, stop: 'calibration-missing', judgeModel: JUDGE_MODEL, required: CALIBRATION_SIZE },
769
+ reds: [{ code: 'calibration-missing', path: 'closeDecl.calibration.cases', detail }],
770
+ refusal: refuse({
771
+ kind: 'decision-ready',
772
+ verb: null,
773
+ path: 'closeDecl.calibration',
774
+ detail,
775
+ options: [
776
+ `author the calibration set from the interview's own Q7 answer (${CALIBRATION_SIZE} cases, mixed polarity) and re-sign`,
777
+ 'drop the judged stage and describe a job whose done is machine-checkable',
778
+ 'abandon the job',
779
+ ],
780
+ }),
781
+ };
782
+ }
783
+
784
+ // ONE CEILING, ALL THE PAID SEAMS (`authorCloseForJob` says the same words over
785
+ // the compile). The book absorbs what the run has already spent, so what the gate
786
+ // reads is the REMAINING balance and never a fresh allowance; `absorb` deliberately
787
+ // does not re-fire a reporter, so nothing is counted twice downstream.
788
+ const book = makeCostBook({ ceilingUsd });
789
+ book.absorb(Array.isArray(priorCalls) ? priorCalls : []);
790
+ const cal = await calibrateFn({
791
+ cases,
792
+ card,
793
+ judgeLoop,
794
+ // the gate's own calls join the same tally the ceiling is read against — a
795
+ // ceiling that cannot see the spend it bounds is F45's blind detector in a
796
+ // money coat. The caller's reporter fires with the call's whole record, after.
797
+ onCost: (/** @type {any} */ c) => {
798
+ book.absorb([{ label: c?.label ?? 'calibrate', costUsd: c?.costUsd ?? null, unpricedRounds: c?.unpricedRounds ?? 0 }]);
799
+ if (typeof onJudgeCost === 'function') onJudgeCost(c);
800
+ },
801
+ capStop: () => book.capStop(),
802
+ });
803
+
804
+ /** what the signing evidence KEEPS about this gate. The per-case artifacts are
805
+ * deliberately not in it — they are already in the signed spec, and a second
806
+ * copy in the evidence file is a second thing to keep in step. What IS kept is
807
+ * WHICH BYTES were certified (`cardHash`/`casesHash`/`setHash`) and BY WHICH
808
+ * MODEL (`judgeModel`), which is what makes a judge-model bump detectable at
809
+ * all. */
810
+ const record = {
811
+ ok: cal.ok,
812
+ stop: cal.stop,
813
+ judgeModel: cal.judgeModel,
814
+ cardHash: cal.cardHash,
815
+ casesHash: cal.casesHash,
816
+ setHash: cal.setHash,
817
+ graded: cal.graded,
818
+ failures: cal.failures,
819
+ injection: cal.injection,
820
+ casualty: cal.casualty,
821
+ costUsd: cal.costUsd,
822
+ knownUsd: cal.knownUsd,
823
+ spendComplete: cal.spendComplete,
824
+ calls: cal.calls?.length ?? 0,
825
+ };
826
+ if (cal.ok) return { ok: true, record, reds: [], refusal: null };
827
+
828
+ const reds = cal.reds.map(scrubRed);
829
+ const casualty = cal.casualty !== null && cal.casualty !== undefined;
830
+ /** the OPERATOR's own governance stop, and it is neither a casualty nor a reading
831
+ * of the close: the ceiling ran out mid-question, and what was graded before it
832
+ * did says nothing either way about the rest of the set. */
833
+ const budget = cal.stop === 'cap-halt' || cal.stop === 'pricing-red';
834
+ const detail = budget
835
+ ? (cal.stop === 'cap-halt'
836
+ ? `The calibration gate ran out of CEILING before it could finish: ${cal.graded.length} of `
837
+ + `${Array.isArray(cases) ? cases.length : 0} case(s) were graded and the next call was never made. A `
838
+ + 'part-graded set is an ungraded ruler, so nothing is signable — and nothing here says the ruler is wrong.'
839
+ : 'The calibration gate stopped on an UNPRICED call: with the run\'s total unknown the ceiling cannot be read '
840
+ + 'against it, and unpriced is never free (F6). Nothing here says anything about the set.')
841
+ : casualty
842
+ ? `The calibration gate could not be RUN: the judge produced no usable facts for ${cal.casualty.kind} `
843
+ + `"${cal.casualty.at}" [${cal.casualty.axis}]. A broken judge is a casualty and never a verdict, so this says `
844
+ + 'nothing about the set — but an ungraded ruler is still an ungraded ruler, and it cannot be signed.'
845
+ : cal.stop === 'no-judge'
846
+ ? `This close carries a judged stage and no judge seam was wired, so the ${CALIBRATION_SIZE}-case gate never ran. `
847
+ + 'An absent seam is a wiring gap, never a fall-back: a gate that grades nothing would certify every close.'
848
+ : cal.stop === 'invalid-set'
849
+ ? 'The stored rubric card and calibration set do not compose into something gradeable, so the gate refused '
850
+ + 'before spending anything.'
851
+ : `The whole pipe did not grade this close's own calibration set correctly: `
852
+ + `${cal.failures.length} of ${Array.isArray(cases) ? cases.length : 0} case(s) graded wrong`
853
+ + `${cal.injection.leaks.length ? ` and ${cal.injection.leaks.length} injection style(s) leaked` : ''}. `
854
+ + `The floor is ${CALIBRATION_SIZE}/${CALIBRATION_SIZE} with itemized reds and all styles resisting `
855
+ + '(hamr, 2026-08-18) — the fix for a miss is a new card line or a corrected case, and a re-sign.';
856
+
857
+ return {
858
+ ok: false,
859
+ record,
860
+ reds,
861
+ refusal: refuse({
862
+ kind: 'decision-ready',
863
+ verb: null,
864
+ path: 'closeDecl.calibration',
865
+ detail,
866
+ options: budget
867
+ ? [
868
+ 'raise the authoring ceiling and re-run the gate — the $0 gates above are already passed',
869
+ 'run the gate unbounded (omit the ceiling) — that is a stated operator choice, never a default',
870
+ 'abandon the job',
871
+ ]
872
+ : casualty || cal.stop === 'no-judge'
873
+ ? [
874
+ 'wire a judge provider pinned to the judged tier and re-run the gate',
875
+ 'wait out the provider and re-run — nothing about the close was judged here',
876
+ 'abandon the job',
877
+ ]
878
+ : [
879
+ 'correct the card line or the case the pipe graded differently, and re-sign',
880
+ 'read the itemized rows above: a case the pipe reds for the WRONG reason is the card talking, not the judge',
881
+ 'abandon the job',
882
+ ],
883
+ }),
884
+ };
885
+ }
886
+
634
887
  /**
635
888
  * D9, in code: *nothing JUDGES the close; three mechanical gates plus a
636
889
  * signature.* No LLM validates another LLM's close, here or anywhere.
@@ -644,6 +897,24 @@ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
644
897
  * A close with NO work stage red at seed has nothing to do and is refused
645
898
  * decision-ready — gate-2 round 2's arm B died exactly there, on a mypy that
646
899
  * scanned nothing and therefore read clean.
900
+ * 4. THE CALIBRATION GATE (softgreen module 5) — for a close carrying a
901
+ * `judged-floor` stage ONLY. The whole judged pipe runs over the signed ten
902
+ * and the five arbiter-owned injection artifacts, and anything short of
903
+ * 10/10 with itemized reds and 5/5 resisted is an unsignable close. It is
904
+ * the ONE gate here that spends money, so it runs LAST — every free refusal
905
+ * has already had its chance, and nobody pays to discover a broken
906
+ * declaration.
907
+ *
908
+ * D9.3 AND THE JUDGED-ONLY CLOSE (2026-08-18 second addendum, ruling 3; hamr:
909
+ * *"fix it now, we are delivering softgreen, isn't that the whole point?"*). Gate
910
+ * 3 protects against a close that cannot fail — one that scans nothing and reads
911
+ * green at seed against a true red. A judged stage SKIPS the seed read by ruling
912
+ * 8, so a close whose only work stage is judged has no seed red to show and used
913
+ * to be unsignable: the one job shape softgreen exists for. For that shape, and
914
+ * ONLY that shape, a PASSED calibration gate plays the seed-red role — it is that
915
+ * close's own proof of failability, held to the same all-or-nothing bar. A close
916
+ * with a mechanical work stage is UNCHANGED: it still needs a seed red, whatever
917
+ * its calibration says.
647
918
  *
648
919
  * Gates 2 and 3 share ONE execution, and that is stated rather than hidden: at
649
920
  * job creation the tree IS the seed, so "does every stage run against the real
@@ -656,18 +927,32 @@ const scrubRed = (r) => /** @type {any} */ (Object.fromEntries(
656
927
  * comes back is the resolved spec, its hash, and the evidence to sign against.
657
928
  *
658
929
  * @param {{spec: any, workdir: string, seedRef?: string|null, shellCapUsd?: number,
659
- * timeoutMs?: number, seedFn?: Function, listingFn?: Function, seedReadFn?: Function}} o
930
+ * timeoutMs?: number, seedFn?: Function, listingFn?: Function, seedReadFn?: Function,
931
+ * judgeLoop?: Function|null, onJudgeCost?: ((c: any) => void)|null, calibrateFn?: Function,
932
+ * ceilingUsd?: number|null, priorCalls?: any[]}} o
660
933
  * @returns {Promise<{ok: boolean, specHash: string|null, seedRef: string|null,
661
934
  * gates: any, work: any[], guards: any[], stops: any[], reds: Red[], refusal: Refusal|null}>}
662
935
  */
663
936
  export async function prepareSigning({
664
937
  spec, workdir, seedRef = null, shellCapUsd, timeoutMs,
665
938
  seedFn = seedAtHead, listingFn = seedListing, seedReadFn = runSeedRead,
939
+ // THE PAID SEAM, and it is the operator's to wire — this module owns no
940
+ // provider and picks none (the same contract `authorCloseForJob` keeps for the
941
+ // drafting model). Absent is never a fall-back: a judged close whose gate could
942
+ // not run is refused, not waved through.
943
+ judgeLoop = null, onJudgeCost = null, calibrateFn = runCalibration,
944
+ // THE OPERATOR'S CEILING, and the spend it has already met. `null`/absent is
945
+ // UNBOUNDED and is a stated choice, never a default invented here (the same
946
+ // contract `authorCloseForJob` keeps). `priorCalls` is what the earlier paid
947
+ // seams of this run already cost — a `makeCostBook().report().calls` list — and
948
+ // it is ABSORBED so the gate reads the remaining balance rather than opening a
949
+ // fresh allowance under the same number.
950
+ ceilingUsd = null, priorCalls = [],
666
951
  }) {
667
952
  /** @type {any} */
668
953
  const base = {
669
954
  ok: false, specHash: null, seedRef: null, work: [], guards: [], stops: [], reds: [], refusal: null,
670
- gates: { declaration: null, precheck: null, seedVerdict: null },
955
+ gates: { declaration: null, precheck: null, seedVerdict: null, calibration: null },
671
956
  };
672
957
 
673
958
  // ── gate 1a: the spec itself. A declared close that is not on a plan-shape
@@ -803,12 +1088,41 @@ export async function prepareSigning({
803
1088
  workRed: workRed.map((r) => r.stage),
804
1089
  };
805
1090
 
1091
+ // ── gate 4: THE CALIBRATION GATE (softgreen module 5). The ONLY paid gate, and
1092
+ // it runs LAST for exactly that reason: every $0 refusal above has already had
1093
+ // its chance, so a broken declaration or a stage that cannot run is found for
1094
+ // free. MANDATORY for any close carrying a judged-floor stage — module 4's
1095
+ // validator checks a stored set for LEGALITY only and deliberately allows a
1096
+ // judged stage with no set at all, because making it mandatory is a signing
1097
+ // decision and this is the signing gate.
1098
+ const judgedStages = (spec.closeDecl.stages ?? []).filter((/** @type {any} */ s) => isObj(s) && s.kind === JUDGED_FLOOR_KIND);
1099
+ if (judgedStages.length) {
1100
+ const cal = await calibrationGate({ spec, judgedStages, judgeLoop, onJudgeCost, calibrateFn, ceilingUsd, priorCalls });
1101
+ base.gates.calibration = cal.record;
1102
+ if (!cal.ok) return { ...base, work, guards, reds: cal.reds, refusal: cal.refusal };
1103
+ }
1104
+
806
1105
  // D9.3 — nothing red at seed means nothing to do. Round 2's arm B read GREEN
807
1106
  // with value 0 because mypy died on an unrelated broken fixture and scanned
808
1107
  // nothing: an instrument scanning nothing reads clean, which is F6's shape at
809
1108
  // the close-authoring layer, and this gate is what refuses it BEFORE a
810
1109
  // signature rather than after a paid run.
811
- if (!workRed.length) {
1110
+ //
1111
+ // THE JUDGED-ONLY ROUTE (ruling 3). A close whose work stages are ALL
1112
+ // seed-exempt has no seed verdict to be red — not because it measures nothing,
1113
+ // but because ruling 8 exempts a judged stage from being measured at seed. Its
1114
+ // proof of failability is the calibration gate, which has just run and passed
1115
+ // (a failed one returned above), and which contains signed cases the pipe must
1116
+ // RED as well as ones it must pass — the polarity law makes "this close can
1117
+ // fail" a thing that was demonstrated rather than assumed. A close with any
1118
+ // mechanical work stage takes the original path unchanged.
1119
+ const mechanicalWork = work.filter((r) => !SEED_EXEMPT_KINDS.includes(r.kind));
1120
+ const judgedOnly = judgedStages.length > 0 && mechanicalWork.length === 0;
1121
+ if (judgedOnly && base.gates.calibration?.ok) {
1122
+ // said out loud in the record: this close passed gate 3 on a DIFFERENT proof,
1123
+ // and a reader must never have to infer which one from an empty workRed list
1124
+ base.gates.seedVerdict.satisfiedBy = 'calibration';
1125
+ } else if (!workRed.length) {
812
1126
  return {
813
1127
  ...base,
814
1128
  work,