diff --git a/test/e2e/oracle.test.ts b/test/e2e/oracle.test.ts index 29800ad9..45565a39 100644 --- a/test/e2e/oracle.test.ts +++ b/test/e2e/oracle.test.ts @@ -462,10 +462,12 @@ describe("predictSection rules", () => { expect(p.mayWrite).toBe(false); }); - test("exclusion folds before grades and witnesses", () => { + test("exclusion folds before grades and witnesses: the section predicts at NO grade", () => { // A declared section outside the `sections` allowlist never runs: the // engine reports it "excluded" before any read, so neither the denied - // grade nor the seeded witness may tighten the prediction. + // grade nor the seeded witness may tighten the prediction, and the grades + // are empty - the section runs at no grade, so preflight and the + // write-granted fold are vacuous over it without recognizing "excluded". const p = predictSection( "labels", meta({ @@ -477,45 +479,85 @@ describe("predictSection rules", () => { liveKinds: { labels: "drift-update" }, }), ); - expect([...p.allowed]).toEqual(["excluded"]); - expect(p.mayWrite).toBe(false); - // An undefined allowlist keeps today's behavior: every section runs. - const unrestricted = predictSection("labels", meta({ mode: "check" })); - expect(unrestricted.allowed.has("excluded")).toBe(false); + expect(p).toEqual({ + key: "labels", + grades: [], + allowed: new Set(["excluded"]), + mayWrite: false, + }); + // An undefined allowlist keeps today's behavior: every section runs, and + // the denied grade with its 403 style reads as a check-mode failure. + const unrestricted = predictSection( + "labels", + meta({ mask: { issues: "none" }, denialStyle: 403, mode: "check" }), + ); + expect(unrestricted).toEqual({ + key: "labels", + grades: ["none"], + allowed: new Set(["failed"]), + mayWrite: false, + }); }); - test("an excluded section does not flip fullyGranted", () => { - // The fixpoint gates (converges / apply_idempotent) quantify over the - // sections that WILL run; a denied-but-excluded section must not block - // them. - const p = predictOutcomes( + test("an excluded NO_READ section in check mode is excluded, not the read-free clean", () => { + // check_suite_preferences makes no request in check mode and is otherwise + // exactly clean; exclusion folds before that rule too, and the read-free + // preflight exemption does not need to see the section since it has no grade. + const p = predictSection( + "check_suite_preferences", meta({ - sections: ["labels", "pages"], - onlySections: ["pages"], - mask: { issues: "none" }, - mode: "apply", - policy: "warn", + sections: ["check_suite_preferences", "labels"], + onlySections: ["labels"], + mask: { checks: "none" }, + mode: "check", }), ); - expect(p.fullyGranted).toBe(true); - expect(p.preflightAborts).toBe("no"); + expect(p).toEqual({ + key: "check_suite_preferences", + grades: [], + allowed: new Set(["excluded"]), + mayWrite: false, + }); }); - test("an excluded denied section never arms the preflight barrier", () => { - // Preflight probes only ACTIVE sections, so a permission-denied section - // that the allowlist excludes cannot abort the run. - const p = predictOutcomes( - meta({ - sections: ["labels"], - onlySections: ["pages"], - mask: { issues: "none" }, - denialStyle: 403, - mode: "apply", - policy: "fail", - }), - ); - expect(p.preflightAborts).toBe("no"); - expect([...p.allowedExitCodes]).toEqual([0]); + test("an excluded denied section beside an active one: the run follows the active one alone", () => { + // apply + fail + 403 with the excluded section's read denied: preflight + // probes only the active section, so the barrier never arms, the excluded + // section is not write-denied (it writes nothing), and the fixpoint gate + // (fullyGranted) quantifies over the section that WILL run. + const excludedDenied = meta({ + sections: ["labels", "pages"], + onlySections: ["pages"], + mask: { issues: "none" }, + denialStyle: 403, + mode: "apply", + policy: "fail", + }); + expect(predictOutcomes(excludedDenied)).toEqual({ + sections: [ + { key: "labels", grades: [], allowed: new Set(["excluded"]), mayWrite: false }, + { key: "pages", grades: ["write"], allowed: new Set(["applied"]), mayWrite: true }, + ], + allowedExitCodes: new Set([0]), + noWritesInCheck: false, + writeDeniedSections: [], + fullyGranted: true, + preflightAborts: "no", + }); + + // The control: the same meta with labels ACTIVE reaches the denied read, + // and the barrier aborts the run. + expect(predictOutcomes({ ...excludedDenied, onlySections: undefined })).toEqual({ + sections: [ + { key: "labels", grades: ["none"], allowed: new Set(["failed"]), mayWrite: false }, + { key: "pages", grades: ["write"], allowed: new Set(["applied"]), mayWrite: true }, + ], + allowedExitCodes: new Set([1]), + noWritesInCheck: false, + writeDeniedSections: ["labels"], + fullyGranted: false, + preflightAborts: "yes", + }); }); test("an EMPTY allowlist is unrestricted, mirroring the engine's size > 0 gate", () => { @@ -575,8 +617,8 @@ describe("predictSection rules", () => { test("exclusion folds before the personal-account no-op", () => { // Both folds precede the grades; an excluded org-only section on a user - // owner is excluded, not applied, and keeps the mask's grades like every - // other excluded section. + // owner is excluded, not applied, and like every other excluded section + // runs at no grade - neither the mask's denial nor the no-op's write grade. const p = predictSection( "teams", meta({ @@ -589,7 +631,7 @@ describe("predictSection rules", () => { ); expect(p).toEqual({ key: "teams", - grades: ["none"], + grades: [], allowed: new Set(["excluded"]), mayWrite: false, }); diff --git a/test/e2e/oracle.ts b/test/e2e/oracle.ts index bae1e7d7..6a587d05 100644 --- a/test/e2e/oracle.ts +++ b/test/e2e/oracle.ts @@ -150,7 +150,16 @@ export function effectiveGrades(grant: MaskGrade, gating: ReadGating): readonly /** The predicted set of outcomes a section may land in, given the run's shape. */ export interface SectionPrediction { key: SectionKey; - /** The grades the section may run at (effectiveGrades); each contributes to `allowed`. */ + /** + * The grades the section may run at (effectiveGrades); each contributes to + * `allowed`. EMPTY for an excluded section (declared, but outside a + * non-empty `sections` allowlist): orchestrate.ts's disposition filter + * classifies it before the preflight list is built and before the section + * loop reads anything, so it runs at no grade, and every fold over these + * grades (preflightDeniable, writeGranted, and through them fullyGranted + * and writeDeniedSections) is vacuous for it by construction - no consumer + * recognizes "excluded" by hand. + */ grades: readonly MaskGrade[]; /** The outcomes the section is allowed to report; the runner must see one. */ allowed: Set; @@ -174,26 +183,23 @@ export function predictSection(key: SectionKey, meta: ScenarioMeta): SectionPred * Two folds precede the grades because they decide whether the mask is * consulted at all: exclusion (the section never runs) and the org-only * no-op on a personal account (the section runs, but never past its - * ungated org probe). + * ungated org probe). Each mints the whole prediction here, grades included. */ export function predictSectionAt( key: SectionKey, meta: ScenarioMeta, gating: ReadGating, ): SectionPrediction { - const grant = sectionGrade(key, meta.mask, meta.orgMask ?? meta.mask); - const grades = effectiveGrades(grant, gating); - // A declared section outside the `sections` allowlist never runs: the engine - // reports it "excluded" before any read (orchestrate.ts), so exclusion folds - // before EVERYTHING - grades, denial semantics, and witnesses alike. An - // EMPTY allowlist means unrestricted, mirroring orchestrate.ts's size > 0 - // gate, so only a non-empty list excludes. + // Exclusion folds before EVERYTHING - grades, denial semantics, and + // witnesses alike (see SectionPrediction.grades). An EMPTY allowlist means + // unrestricted, mirroring orchestrate.ts's size > 0 gate, so only a + // non-empty list excludes. if ( meta.onlySections !== undefined && meta.onlySections.length > 0 && !meta.onlySections.includes(key) ) { - return { key, grades, allowed: new Set(["excluded"]), mayWrite: false }; + return { key, grades: [], allowed: new Set(["excluded"]), mayWrite: false }; } // An org-only section on a personal account no-ops regardless of mask: its // org probe (declared permission "none", so no mask key gates it) 404s and @@ -211,6 +217,8 @@ export function predictSectionAt( mayWrite: false, }; } + const grant = sectionGrade(key, meta.mask, meta.orgMask ?? meta.mask); + const grades = effectiveGrades(grant, gating); const deniedAtGatedRead = gating === "mixed" && grant === "read"; const arms = grades.flatMap((grade) => grade === "none" && deniedAtGatedRead @@ -349,10 +357,7 @@ export interface RunPrediction { noWritesInCheck: boolean; /** Sections whose denied writes must never mutate state (mock rule 4). */ writeDeniedSections: SectionKey[]; - /** - * True when every ACTIVE section is write-granted (convergence expected). - * Excluded sections never run, so they do not count against this. - */ + /** True when every section is write-granted (convergence expected). */ fullyGranted: boolean; /** * Whether the run aborts at the preflight barrier (apply + fail policy, a denied @@ -416,7 +421,7 @@ function foldPreflightAbort(verdicts: readonly PreflightAbort[]): PreflightAbort return verdicts.includes("possible") ? "possible" : "no"; } -/** True when the section runs write-granted for certain (its only effective grade is write). */ +/** True when every effective grade is write: the section runs write-granted for certain. */ function writeGranted(section: SectionPrediction): boolean { return section.grades.every((grade) => grade === "write"); } @@ -427,11 +432,6 @@ function writeGranted(section: SectionPrediction): boolean { * grades make it "possible": the probe reaches the gated read only for some content. */ export function preflightDeniable(section: SectionPrediction, meta: ScenarioMeta): PreflightAbort { - // Preflight only probes ACTIVE sections (orchestrate.ts filters by the - // allowlist first), so an excluded section can never arm the barrier. - if (section.allowed.has("excluded")) { - return "no"; - } if (NO_READ_SECTIONS.has(section.key)) { // No read endpoints: preflight probes nothing, so the barrier cannot arm. return "no"; @@ -475,9 +475,7 @@ export function predictOutcomes(meta: ScenarioMeta): RunPrediction { allowedExitCodes: exitCodes, noWritesInCheck: check, writeDeniedSections: sections.filter((s) => !writeGranted(s) && !s.mayWrite).map((s) => s.key), - // Excluded sections never run, so they cannot break convergence or - // idempotence: fullyGranted quantifies over the sections that WILL run. - fullyGranted: sections.every((s) => s.allowed.has("excluded") || writeGranted(s)), + fullyGranted: sections.every(writeGranted), preflightAborts, }; }