Skip to content

Commit 36b7a6c

Browse files
lex00claude
andcommitted
feat: adopt chant 0.55.0's live-denominator removalDeltaCap
Bump @intentius/chant and @intentius/chant-lexicon-github to ^0.55.0 and delete the warden-local removalLiveCap guardrail: chant's removalDeltaCap now takes a managedTotal live denominator (chant#2067), so runGuardrails passes the countLiveManaged value straight through as { maxFraction, managedTotal }. The runner's diff→guardrails sequencing capture and its lock test are unchanged, as are adminFloor / requiredAdmins / requireSelf and their member-visibility gating. Tests now assert the same behavioral cases through the upstream function, and the docs no longer describe the cap as warden-local. Action bundle rebuilt for the dependency bump. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RErsihpfppjyZ37vQuRwKb
1 parent a6247c2 commit 36b7a6c

17 files changed

Lines changed: 97 additions & 149 deletions

CYCLES.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ Shared behavior, so it isn't repeated thirteen times:
2020
resource types; see [POLICY.md](POLICY.md)) marks resources owned; a
2121
programmatic `diffOptions.isOwned` predicate overrides the declaration when
2222
supplied.
23-
- **Guardrails before apply.** `removalLiveCap` refuses an apply whose
23+
- **Guardrails before apply.** `removalDeltaCap` refuses an apply whose
2424
deletes exceed 25% of the live managed entries in the collections the
2525
policy declares; with nothing live to measure against it falls back to
26-
chant's plan-relative `removalDeltaCap` (deletes over the plan's updates
27-
plus deletes). `adminFloor` (at least 2 org admins must remain) always
26+
its plan-relative denominator (deletes over the plan's updates plus
27+
deletes). `adminFloor` (at least 2 org admins must remain) always
2828
runs too; `requiredAdmins` and `requireSelf` run when configured
2929
programmatically. `resolveRenames` collapses a `previously`-marked
3030
delete+create pair into an update first, so a rename is not counted as a
@@ -114,7 +114,7 @@ who is an admin.
114114
- Member entries are keyed by `login`. Removal of an undeclared live member
115115
requires marking `member` owned (`owned: true` or `owned: [member, ...]`
116116
on the org). Removals, once enabled, run the member-aware guardrails in
117-
full (`adminFloor`, `requiredAdmins`, `requireSelf`, `removalLiveCap`);
117+
full (`adminFloor`, `requiredAdmins`, `requireSelf`, `removalDeltaCap`);
118118
`requireSelf` means the managing identity must remain an org admin rather
119119
than a plain member.
120120
- The schema does not model outside collaborators (a per-repo concept), so
@@ -140,7 +140,7 @@ Reconciles the team tree, team membership/roles, and team-to-repo permissions
140140
disturbed. Deleting a team, team member, or team repo requires ownership.
141141
- Rename: a `previously` slug makes the guardrail layer collapse
142142
`delete(old)` + `create(new)` into one update, so a rename doesn't count
143-
toward `removalLiveCap`. When teams are not owned the delete half is
143+
toward `removalDeltaCap`. When teams are not owned the delete half is
144144
never emitted anyway, so a rename appears purely as a create and the old
145145
team is left in place. The runner does not yet perform an atomic
146146
GitHub-side rename.
@@ -256,7 +256,7 @@ expired, over-max-lifetime, or idle grants lose their org access.
256256
`POST /orgs/{org}/personal-access-tokens/{pat_id}`, which revokes the
257257
grant's org access. Violations are emitted as updates ("revoke org
258258
access") on one `token-grant` entry per grant, rather than deletes, so a
259-
routine sweep does not trip `removalLiveCap`.
259+
routine sweep does not trip `removalDeltaCap`.
260260
- Callable only by a GitHub App; a PAT cannot drive this cycle. The API
261261
cannot create or rotate a user's PAT, so revoking org access is the
262262
enforcement lever. The expiry check uses GitHub's own `expired` flag;

POLICY.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ It is the one file you must author.
2020
resource collection it reconciles there; `owned: [team, repo, ...]` limits
2121
ownership to the listed change-set resource types. A programmatic
2222
`diffOptions.isOwned` predicate, when supplied, overrides the declaration.
23-
Owned deletes still run the guardrails (`removalLiveCap` etc.) before any
23+
Owned deletes still run the guardrails (`removalDeltaCap` etc.) before any
2424
apply.
2525

2626
One authoring note: the CLI ships a small built-in YAML reader that handles
@@ -418,7 +418,7 @@ only approve or deny; the requested repo scope cannot be changed.
418418

419419
## What the policy does not declare
420420

421-
- **Guardrail thresholds.** `removalLiveCap` (default: deletes capped at 25%
421+
- **Guardrail thresholds.** `removalDeltaCap` (default: deletes capped at 25%
422422
of the live managed entries in the collections the policy declares),
423423
`adminFloor` (default: at least 2 org
424424
admins must remain), `requiredAdmins`, and `requireSelf` are configured

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ Before any apply, warden runs safety checks and refuses dangerous changes
241241

242242
| Guardrail | What it refuses or protects |
243243
|---|---|
244-
| `removalLiveCap` | Refuses an apply whose deletes exceed 25% of the live managed entries in the collections the policy declares (typo protection). With nothing live to measure against it falls back to chant's plan-relative `removalDeltaCap`. |
244+
| `removalDeltaCap` | Refuses an apply whose deletes exceed 25% of the live managed entries in the collections the policy declares (typo protection). With nothing live to measure against it falls back to the plan-relative denominator. |
245245
| `adminFloor` | Refuses if fewer than 2 org admins would remain. |
246246
| `requiredAdmins` / `requireSelf` | Keep named admins (and the managing identity) from being removed. |
247247
| rename-without-loss | A `previously` alias collapses a delete+create into an update, so a rename doesn't count as a deletion. |
@@ -337,7 +337,7 @@ The hermetic compose smoke drives every cycle's full loop against a stateful
337337
mock GitHub (dependency-free node, run via docker compose or directly). Each
338338
cycle applies its policy slice and then re-plans to convergence, with
339339
out-of-band drift corrected and deletes exercised under `owned`. The suite
340-
also trips the `removalLiveCap` block and walks the permission-gated 403 NOTE
340+
also trips the `removalDeltaCap` block and walks the permission-gated 403 NOTE
341341
path, and auth runs the real App JWT and installation-token flow against the
342342
mock.
343343

action/index.mjs

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,7 @@ function diffCollection(params2) {
349349
out.push({ kind: "delete", resourceType, key: entryKey, before: l });
350350
}
351351
}
352+
return live.size;
352353
}
353354
function summarizeChangeSet(cs) {
354355
const counts = { create: 0, update: 0, delete: 0 };
@@ -419,13 +420,24 @@ function resolveRenames(changeSet) {
419420
const filteredEntries = changeSet.entries.filter(
420421
(e) => !(e.kind === "delete" && resolvedDeletes.has(e.key)) && !(e.kind === "create" && resolvedCreates.has(e.key))
421422
);
422-
return { org: changeSet.org, entries: [...filteredEntries, ...syntheticUpdates] };
423+
return { ...changeSet, entries: [...filteredEntries, ...syntheticUpdates] };
423424
}
424425
function removalDeltaCap(changeSet, opts = {}) {
425426
const maxFraction = opts.maxFraction ?? 0.25;
427+
const deletes = changeSet.entries.filter((e) => e.kind === "delete").length;
428+
const managedTotal = opts.managedTotal ?? changeSet.managedCount;
429+
if (managedTotal !== void 0 && managedTotal > 0) {
430+
const fraction2 = deletes / managedTotal;
431+
if (fraction2 > maxFraction) {
432+
return {
433+
guardrail: "removalDeltaCap",
434+
message: `${deletes} of ${managedTotal} live managed entries (${Math.round(fraction2 * 100)}%) would be deleted, exceeding the ${Math.round(maxFraction * 100)}% threshold. Check for typos in config or raise maxFraction to proceed.`
435+
};
436+
}
437+
return null;
438+
}
426439
const total = changeSet.entries.filter((e) => e.kind !== "create").length;
427440
if (total === 0) return null;
428-
const deletes = changeSet.entries.filter((e) => e.kind === "delete").length;
429441
const fraction = deletes / total;
430442
if (fraction > maxFraction) {
431443
return {
@@ -1054,19 +1066,6 @@ var init_diff = __esm({
10541066
});
10551067

10561068
// src/reconcile/guardrails.ts
1057-
function removalLiveCap(changeSet, liveManagedTotal, opts = {}) {
1058-
if (liveManagedTotal === 0) return removalDeltaCap(changeSet, opts);
1059-
const maxFraction = opts.maxFraction ?? 0.25;
1060-
const deletes = changeSet.entries.filter((e) => e.kind === "delete").length;
1061-
const fraction = deletes / liveManagedTotal;
1062-
if (liveManagedTotal > 0 && fraction > maxFraction) {
1063-
return {
1064-
guardrail: "removalLiveCap",
1065-
message: `${deletes} of ${liveManagedTotal} live managed entries (${Math.round(fraction * 100)}%) would be deleted, exceeding the ${Math.round(maxFraction * 100)}% threshold. Check for typos in config or raise maxFraction to proceed.`
1066-
};
1067-
}
1068-
return null;
1069-
}
10701069
function adminFloor(changeSet, live, opts = {}) {
10711070
const min = opts.min ?? 2;
10721071
const liveMembers = live.members ?? [];
@@ -1124,11 +1123,10 @@ function requireSelf(changeSet, live, opts) {
11241123
function runGuardrails(changeSet, live, config2 = {}, liveManagedTotal = 0) {
11251124
const resolved = resolveRenames(changeSet);
11261125
const diagnostics = [];
1127-
const cap = removalLiveCap(
1128-
resolved,
1129-
liveManagedTotal,
1130-
config2.removalLiveCap ?? config2.removalDeltaCap
1131-
);
1126+
const cap = removalDeltaCap(resolved, {
1127+
maxFraction: config2.removalDeltaCap?.maxFraction,
1128+
managedTotal: liveManagedTotal > 0 ? liveManagedTotal : config2.removalDeltaCap?.managedTotal
1129+
});
11321130
if (cap) diagnostics.push(cap);
11331131
const memberAware = live.members !== void 0 || resolved.entries.some((e) => e.resourceType === "member");
11341132
if (memberAware) {
@@ -238803,8 +238801,8 @@ var package_default = {
238803238801
prepublishOnly: "npm run build"
238804238802
},
238805238803
dependencies: {
238806-
"@intentius/chant": "^0.54.3",
238807-
"@intentius/chant-lexicon-github": "^0.54.3"
238804+
"@intentius/chant": "^0.55.0",
238805+
"@intentius/chant-lexicon-github": "^0.55.0"
238808238806
},
238809238807
devDependencies: {
238810238808
"@types/libsodium-wrappers": "^0.7.14",

e2e/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ auth.
5252

5353
"yes" means exercised and asserted by the smoke; "n/a" means the behavior
5454
does not exist for that cycle (the reason is noted). Deletes run under
55-
`owned`, with the `removalLiveCap` arithmetic spelled out in each test.
55+
`owned`, with the `removalDeltaCap` arithmetic spelled out in each test.
5656

5757
| Cycle | Read | Apply | Converge | Drift | Delete via `owned` | Gated-read NOTE |
5858
|---|---|---|---|---|---|---|
@@ -74,7 +74,7 @@ Cross-cutting behaviors, also in the smoke:
7474

7575
- Read-only fetchLive is asserted for all 13 cycles before any apply.
7676
- The guardrail block path: shrinking the member policy to drop 1 of 3 live
77-
members trips `removalLiveCap` (33% over the 25% cap), blocks the apply,
77+
members trips `removalDeltaCap` (33% over the 25% cap), blocks the apply,
7878
and leaves the member in place.
7979
- The permission-gated 403 NOTE path: the mock 403s the org-variables read;
8080
the cycle plans optimistically and the plan carries the

e2e/smoke.e2e.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* See e2e/README.md for the cycle-by-cycle table. Per cycle: apply from a
1616
* policy, re-run to a converged (empty) plan, mutate out-of-band and re-run to
1717
* correct the drift, and (where a delete is plannable) a real delete under
18-
* `owned`. Plus the guardrail-block path (removalLiveCap trip on a
18+
* `owned`. Plus the guardrail-block path (removalDeltaCap trip on a
1919
* mass-removal policy edit) and the permission-gated 403 NOTE path (the mock
2020
* 403s one slice). Auth goes through the real App flow: a generated RSA key
2121
* signs the App JWT, the mock's /app/installations/{id}/access_tokens mints
@@ -353,7 +353,7 @@ suite("github-warden smoke (mock GitHub)", () => {
353353
expect(members.body.map((m) => m.login)).not.toContain("stray");
354354
});
355355

356-
it("removalLiveCap blocks dropping 1 of 3 members from the policy (33% > 25%)", async () => {
356+
it("removalDeltaCap blocks dropping 1 of 3 members from the policy (33% > 25%)", async () => {
357357
const shrunk: OrgConfig = {
358358
owned: ["member"],
359359
members: POLICY.members!.filter((m) => m.login !== "bob"),
@@ -363,7 +363,7 @@ suite("github-warden smoke (mock GitHub)", () => {
363363
expect(cr.guardrailBlocked).toBe(true);
364364
expect(cr.guardrails.ok).toBe(false);
365365
if (!cr.guardrails.ok) {
366-
expect(cr.guardrails.diagnostics[0]!.guardrail).toBe("removalLiveCap");
366+
expect(cr.guardrails.diagnostics[0]!.guardrail).toBe("removalDeltaCap");
367367
expect(cr.guardrails.diagnostics[0]!.message).toContain("1 of 3 live managed entries");
368368
}
369369
// Nothing was removed.
@@ -567,7 +567,7 @@ suite("github-warden smoke (mock GitHub)", () => {
567567
// = 33% — over the default 25% cap, so raise it for the deliberate cleanup.
568568
const cr = only(
569569
await reconcile(["environments"], cfg, {
570-
guardrails: { removalLiveCap: { maxFraction: 0.5 } },
570+
guardrails: { removalDeltaCap: { maxFraction: 0.5 } },
571571
}),
572572
);
573573
expect(cr.counts.delete).toBe(1);

package-lock.json

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@
3535
"prepublishOnly": "npm run build"
3636
},
3737
"dependencies": {
38-
"@intentius/chant": "^0.54.3",
39-
"@intentius/chant-lexicon-github": "^0.54.3"
38+
"@intentius/chant": "^0.55.0",
39+
"@intentius/chant-lexicon-github": "^0.55.0"
4040
},
4141
"devDependencies": {
4242
"@types/libsodium-wrappers": "^0.7.14",

src/cycles/membership.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
* default runner wiring (no predicate) this cycle only ADDS or re-roles
2727
* declared members and never removes anyone — the safe default. When removals
2828
* ARE enabled, the `adminFloor`, `requiredAdmins`, `requireSelf`, and
29-
* `removalLiveCap` guardrails (all member-aware) gate the apply.
29+
* `removalDeltaCap` guardrails gate the apply.
3030
*/
3131

3232
import type { AppClient } from "../auth/app-client.js";

src/cycles/teams.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
* A `TeamConfig.previously` slug marks a rename. Its effect is at the GUARDRAIL
3636
* layer: `resolveRenames` collapses a `delete(previously)` + `create(slug)`
3737
* pair into a single update so the rename does not count against
38-
* `removalLiveCap` (a rename is not a mass-deletion). The delete half only
38+
* `removalDeltaCap` (a rename is not a mass-deletion). The delete half only
3939
* exists when the old slug is owned (`DiffOptions.isOwned`); with the safe
4040
* default (no ownership predicate) a renamed team is emitted purely as a
4141
* create, leaving the old team in place — nothing is deleted, so nothing is

0 commit comments

Comments
 (0)