morgana: row 204 (193a, #1702) covers takes a required least level (Duckworth N1 on R1, sage-1420), unknown grant level covers nothing, 7/7 mutants killed, manifest

This commit is contained in:
jason.woltje committed 2026-10-20 15:08:28 -05:00
1 parent e9ce59cc43
commit 0caf6e2f67
8 files changed
+199 -78

No files matched your search

+62 -47
View File
@@ -1,67 +1,82 @@
# Row 193a build: the viewer level on R1's access file
# Row 204 (193a) build: the viewer level on R1's access file
Morgana (Opus 5.5), 2026-10-20. Queue row 204, Gitea #1702 (parent row 193, #1691), design
`agents/morgana/work/queue-193/DESIGN.md` r5 (e2d161e2e on morgana/row193, approved by Duckworth,
duckworth-0018). Go: sage-1373 item 3. Chains on row 201 (FED R1) round 1 candidate gizmoduck/fed-r1 at
44e36fc4c (sage-1412, Q177). Lands after row 201 and rebases onto its landing.
duckworth-0018). Go: sage-1373 item 3, sage-1420. Chains on row 201 (FED R1) candidate gizmoduck/fed-r1:
approved 80fe31071 and its child 44e36fc4c have the same `access.mjs` (44e36fc4c only drops a `.pyc`),
so the branch sits on 44e36fc4c (sage-1420 item 3). Lands after row 201 and rebases onto its landing.
Manifest: `candidate-manifest.sha256` in this directory. Base: `base.txt`.
## What it is
Design 4.2: R1 refuses any grant level but `collaborator`. Row 193a widens that check to `collaborator` or
`viewer` and refuses anything else. `covers` needs no change: FED 3.5 already compares the grant's level
with the one asked for, so a viewer grant matches `{}` and `{level: 'viewer'}`, and fails
`{level: 'collaborator'}`.
Two changes to R1's `packages/business/src/access.mjs`:
## The change
1. **Viewer level** (design 4.2). `LEVELS` becomes `["collaborator", "viewer"]`, most to least. R1's level
check already names `grants[<i>].level`, so the refusal path doesn't move.
2. **`covers` level required** (Duckworth's N1 on R1, sage-1420 item 2). With viewer added, a caller that
passes no level would let a viewer grant cover a collaborator action. Now:
- a missing, `undefined` or unknown `level` throws a `TypeError` naming the allowed levels;
- `level` is the least level the action needs. `{level: 'viewer'}` means read, and any grant covers it.
`{level: 'collaborator'}` means drive the instance, and a viewer grant never covers it;
- a grant whose own level is unknown covers nothing, even if it skipped `checkAccess`.
On R1's candidate (44e36fc4c):
## Interface change for callers (R3, R4, R5 and row 193b1)
- `packages/business/src/access.mjs`: `LEVELS` becomes `["collaborator", "viewer"]`. That is the one
source line. R1's level check at :136 already names `grants[<i>].level`, so nothing else moves. Its
comment now says what a viewer is.
- `packages/business/tests/access.test.mjs` (R1's): three places pinned collaborator only. The `LEVELS`
test now expects both levels. The refused-level test drops `viewer` from its bad list, adds `Viewer`,
and both its regexes read `must be one of collaborator, viewer`. R1's `covers` test at :272
(a collaborator grant fails `{level: 'viewer'}`) is unchanged and still holds.
`covers(grant, target, now, {level})` must pass `level`. There is no "any level" call any more. Where FED
r4.5 and row 193's design 9 say `{}` (share reads, `project.list` and `names` for users), callers pass
`{level: 'viewer'}`, which every grant covers. Collaborator actions pass `{level: 'collaborator'}` as before.
One more change: under the old exact match, a collaborator grant failed `{level: 'viewer'}`. Under the least
level it covers it. No caller asked for exact viewer.
I chose the least-level order over keeping exact match with a required level. Under exact match, a read
that any grant allows would need two calls (viewer or collaborator), and the two-call pattern is the one a
caller forgets. The cost: `LEVELS` order is now meaningful, and the source comment says so.
## Files
- `packages/business/src/access.mjs`: `LEVELS`, its comment, and `covers`'s level check and comment.
- `packages/business/tests/access.test.mjs` (R1's): four places assumed collaborator only or exact match.
- The `LEVELS` test expects both levels.
- The refused-level test drops `viewer` from its bad list and adds `Viewer`. Its two regexes read
`must be one of collaborator, viewer`.
- The 11 `covers` calls that passed no level now pass `READ` (`{level: 'viewer'}`).
- The old "no filter accepts any" test becomes the least-level test.
- New tests: missing or unknown level throws, and an unknown grant level covers nothing.
- The `now` tests pass `READ` so they still reach only the Date check.
- `packages/business/tests/access-levels.test.mjs` (new): the row 193a tests.
No other test or script reads either source or test path (grep over `packages/*/tests` and `scripts/`).
R1's own mutant M32 (`if (false) refuse(`) still applies to the same line.
## R1's interface (confirmed, gizmoduck-0029)
FED r4.5 names `covers(grant, {business, project, instance}, now, {level} = {})` exactly. Gizmoduck
confirmed the validator before R1's manifest went to Duckworth:
- `checkAccess(doc, {providers, peers, businesses, ssoSubjects})` is pure, returns the checked document
frozen, and throws a `BusinessError` (exit 2) naming the key path on any failure;
- `businesses` maps a business id to `{human, instances}`;
- `ssoSubjects` is a required array of `{provider, sub}` from `console.sso.subjects`; the test passes `[]`;
- a grant with no `level` refuses; an expired grant passes `checkAccess` and fails `covers`.
Every call into the validator goes through one `check()` helper at the top of the test file, so a different
name or shape changes one place at rebase. The `covers` calls follow FED exactly.
A grant with no `level` refuses (`undefined` in the refused list), which R1 also does.
`index.mjs` is unchanged: it already exports `LEVELS` and `covers`.
## Evidence
Run on 2026-10-20 in the row193a worktree with `node --test --test-reporter=tap <file>` from
`packages/business`, each file alone:
From `packages/business`, each file alone with `node --test --test-reporter=tap <file>`, 2026-10-20:
- On R1's candidate with the change: `access-levels.test.mjs` tests 4, pass 4, fail 0;
`access.test.mjs` tests 24, pass 24, fail 0.
- Before R1 (on aa1bd209e): the new file fails to load, `ERR_MODULE_NOT_FOUND`.
- Throwaway reference, before R1: an `access.mjs` with only the level rule, a declared-business rule and FED's
`covers`, placed in `src/`, run, then deleted, never committed. Result: pass 4, fail 0.
- `access-levels.test.mjs`: tests 5, pass 5, fail 0.
- `access.test.mjs`: tests 26, pass 26, fail 0.
Mutants on that reference, each failing exactly one test:
Mutants: `mutants.py` in this directory. It runs from the worktree root, backs up `access.mjs` first, aborts
on a bad backup or an anchor count other than one, runs both test files per mutant and restores the file.
Log: `mutants.log`. Result: killed 7/7, file restored. Sage gave the go for this run (sage-1420 item 1).
| Mutant | Failing test |
| Mutant | Killed by |
|---|---|
| levels back to collaborator only | 1, "a grant's level is collaborator or viewer" |
| `owner` added to the levels | 2, "any other level refuses with the key path named" |
| `covers` ignores the asked level | 3, "covers tells a viewer grant from a collaborator grant" |
| `covers` passes any grant when collaborator is asked | 3 |
| A1 levels collaborator only | 11 tests across both files |
| A2 owner added | levels 2; access 3, 13, 19, 24 |
| A3 covers ignores level | levels 3; access 23 |
| A4 order inverted (collaborator ask passes a viewer grant) | levels 3; access 20, 22, 23 |
| A5 level optional again (N1) | levels 4; access 24 |
| A5b level optional, no level means viewer | levels 4; access 24 |
| A6 unknown grant level guard dropped | access 25 |
The same four mutants on R1's real code need Sage's slot (mutant batch). Results go here before review.
R1's own `agents/gizmoduck/work/queue-201/mutants.py` (44 mutants) is not rerun here. Its M43 anchor
(`if (level !== undefined && grant.level !== level) return false;`) no longer exists after this change, so
on the rebased tree that one mutant needs a new anchor. M32's anchor is still unique. That is for R1's gate on landing order. 193a's own mutants
above cover the changed lines.
## Earlier history
Started before R1 existed against FED r4.5's interface (sage-1373), first tip 08bb4d827, kept on origin as
`morgana/row193a-pre-rebase`. The rebase onto R1 was pushed with `--force-with-lease`, which broke AGENTS.md
invariant 8. Reported in morgana-0016. Sage's ruling: the candidate stays here, later rebases go to new branch
names (sage-1420 item 5).
+1
View File
@@ -0,0 +1 @@
44e36fc4c6e44dc9c10adcdfa5cc3d124fa1c80a
@@ -0,0 +1,3 @@
0c1d9a126ee9e3cd21f946350387bbcd123c95a74ba22a6e230a7a4740b9b2ae packages/business/src/access.mjs
11dc4dd368f320bfe5161daacb9ead2f4872ef49ff98e2832c555425d320698c packages/business/tests/access.test.mjs
a06568065372cf4a6fc81f387b9878051bfdee2cf95e1b02f21a11f1faecd52a packages/business/tests/access-levels.test.mjs
@@ -0,0 +1,35 @@
KILLED A1 levels collaborator only
access-levels.test.mjs: 1 - a grant's level is collaborator or viewer
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access-levels.test.mjs: 5 - a viewer grant still needs its business, project, instance and expiry to cover
access.test.mjs: 3 - LEVELS is the single allowed level set, collaborator and viewer (row 193a)
access.test.mjs: 13 - grants: any level other than collaborator or viewer refuses, naming grants[i].level
access.test.mjs: 20 - covers: * and listed ids match
access.test.mjs: 21 - covers: an unlisted project, instance or business doesn't match
access.test.mjs: 22 - covers: an expired grant doesn't match, at and after the instant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
access.test.mjs: 25 - covers: a grant with an unknown level covers nothing (row 193a)
KILLED A2 owner added
access-levels.test.mjs: 2 - any other level refuses with the key path named
access.test.mjs: 3 - LEVELS is the single allowed level set, collaborator and viewer (row 193a)
access.test.mjs: 13 - grants: any level other than collaborator or viewer refuses, naming grants[i].level
access.test.mjs: 19 - loadAccess refuses bad JSON and a bad document with exit 2
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A3 covers ignores level
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
KILLED A4 order inverted (collaborator ask passes a viewer grant)
access-levels.test.mjs: 3 - covers tells a viewer grant from a collaborator grant
access.test.mjs: 20 - covers: * and listed ids match
access.test.mjs: 22 - covers: an expired grant doesn't match, at and after the instant
access.test.mjs: 23 - covers: level is the least level asked for (row 193a)
KILLED A5 level optional again (N1)
access-levels.test.mjs: 4 - covers refuses a call with no level, so a viewer grant can't slip past a collaborator check
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A5b level optional, no level means any
access-levels.test.mjs: 4 - covers refuses a call with no level, so a viewer grant can't slip past a collaborator check
access.test.mjs: 24 - covers: a missing or unknown level throws (row 193a, N1)
KILLED A6 unknown grant level guard dropped
access.test.mjs: 25 - covers: a grant with an unknown level covers nothing (row 193a)
killed 7/7; restored: True
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
# Row 204 (193a) mutants on packages/business/src/access.mjs. Run from the row193a worktree root.
import os, shutil, subprocess, sys
SRC = os.path.abspath("packages/business/src/access.mjs")
BAK = "/mnt/storage/scratch/morgana-r204/access.mjs.orig" # any scratch path outside the tree
TESTS = ["tests/access-levels.test.mjs", "tests/access.test.mjs"]
CMP = " if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;"
MUTANTS = [
("A1 levels collaborator only", 'export const LEVELS = Object.freeze(["collaborator", "viewer"]);', 'export const LEVELS = Object.freeze(["collaborator"]);'),
("A2 owner added", 'export const LEVELS = Object.freeze(["collaborator", "viewer"]);', 'export const LEVELS = Object.freeze(["collaborator", "viewer", "owner"]);'),
("A3 covers ignores level", CMP, " if (!LEVELS.includes(grant.level)) return false;"),
("A4 order inverted (collaborator ask passes a viewer grant)", CMP, CMP.replace("> LEVELS.indexOf(level)", "< LEVELS.indexOf(level)")),
("A5 level optional again (N1)", " if (!LEVELS.includes(level)) throw", " if (level !== undefined && !LEVELS.includes(level)) throw"),
("A5b level optional, no level means any", " if (!LEVELS.includes(level)) throw", " if (level === undefined) level = \"viewer\";\n if (!LEVELS.includes(level)) throw"),
("A6 unknown grant level guard dropped", CMP, " if (LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;"),
]
shutil.copyfile(SRC, BAK)
orig = open(BAK).read()
if orig != open(SRC).read(): sys.exit("backup mismatch, abort")
killed = 0
try:
for name, a, b in MUTANTS:
if orig.count(a) != 1: sys.exit(f"{name}: anchor count {orig.count(a)}, abort")
open(SRC, "w").write(orig.replace(a, b))
failing = []
for t in TESTS:
p = subprocess.run(["node", "--test", "--test-reporter=tap", t], cwd="packages/business", capture_output=True, text=True, timeout=60)
failing += [f"{t.split('/')[-1]}: {l[7:]}" for l in p.stdout.splitlines() if l.startswith("not ok")]
status = "KILLED" if failing else "SURVIVED"
killed += bool(failing)
print(f"{status} {name}")
for f in failing: print(f" {f}")
finally:
shutil.copyfile(BAK, SRC)
print(f"killed {killed}/{len(MUTANTS)}; restored: {open(SRC).read() == orig}")
+9 -4
View File
@@ -10,7 +10,8 @@ import { refuse } from "./errors.mjs";
import { configDir } from "./business.mjs";
import { isPlainObject, requireObject, rejectUnknownKeys, requireId, requirePositiveInt, readJsonFile, deepFreeze } from "./util.mjs";
// The grant levels (row 193a, #1691): a viewer reads threads shared with it and drives no instance.
// The grant levels, most to least (row 193a, #1691): a viewer reads threads shared with it and drives
// no instance. covers() relies on this order.
export const LEVELS = Object.freeze(["collaborator", "viewer"]);
// Ids a user may never take: the bus's own principals.
const RESERVED_USERS = Object.freeze(["human", "tester"]);
@@ -172,14 +173,18 @@ export function loadAccess(file, context, { uid = process.getuid() } = {}) {
}
// Does a checked grant cover a thread? `target` is the thread's business, bus
// project id and instance. With `level`, the grant's level must be that one;
// without it, any level covers. An expired grant covers nothing.
// project id and instance. `level` is required (row 193a, Duckworth's N1 on R1):
// it is the least level the action needs (LEVELS runs from most to least), so `viewer` asks for
// read and any grant covers it, while `collaborator` asks to drive the instance
// and a viewer grant never covers it. An expired grant covers nothing.
export function covers(grant, { business, project, instance }, now, { level } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
if (grant.business !== business) return false;
if (!(grant.projects[0] === ALL || grant.projects.includes(project))) return false;
if (!(grant.instances[0] === ALL || grant.instances.includes(instance))) return false;
if (level !== undefined && grant.level !== level) return false;
// A grant whose own level is unknown covers nothing, even if it skipped checkAccess.
if (!LEVELS.includes(grant.level) || LEVELS.indexOf(grant.level) > LEVELS.indexOf(level)) return false;
return true;
}
+17 -10
View File
@@ -5,7 +5,7 @@ import * as access from "../src/access.mjs";
// Row 193a (#1691): the viewer level. R1 (federation design r4.5, section 7) owns access.mjs and its
// tests; this file pins only what row 193 adds: a grant's level is collaborator or viewer, and covers()
// tells the two apart. Every call into R1 goes through check() below, so a change to R1's loader
// tells the two apart with a required least level. Every call into R1 goes through check() below, so a change to R1's loader
// interface edits one place.
const CONTEXT = {
providers: ["authentik"],
@@ -54,21 +54,28 @@ test("covers tells a viewer grant from a collaborator grant", () => {
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const viewer = file("viewer").grants[0];
const collaborator = file("collaborator").grants[0];
// Share reads ask for any level (design 9); create, open, send and share ask for collaborator.
assert.equal(access.covers(viewer, target, NOW, {}), true);
// The level is the least one the action needs: share reads ask viewer; create, open, send and share ask
// collaborator (design 9).
assert.equal(access.covers(viewer, target, NOW, { level: "viewer" }), true);
assert.equal(access.covers(viewer, target, NOW, { level: "collaborator" }), false);
assert.equal(access.covers(collaborator, target, NOW, {}), true);
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), true);
assert.equal(access.covers(collaborator, target, NOW, { level: "collaborator" }), true);
assert.equal(access.covers(collaborator, target, NOW, { level: "viewer" }), false);
});
test("covers refuses a call with no level, so a viewer grant can't slip past a collaborator check", () => {
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
const viewer = file("viewer").grants[0];
assert.throws(() => access.covers(viewer, target, NOW), TypeError);
assert.throws(() => access.covers(viewer, target, NOW, {}), TypeError);
});
test("a viewer grant still needs its business, project, instance and expiry to cover", () => {
const viewer = { ...file("viewer").grants[0], projects: [PROJECT], expires: "2026-10-21T00:00:00Z" };
const target = { business: "creative-hub", project: PROJECT, instance: "researcher" };
assert.equal(access.covers(viewer, target, NOW, {}), true);
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, {}), false);
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, {}), false);
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, {}), false);
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), {}), false);
const read = { level: "viewer" };
assert.equal(access.covers(viewer, target, NOW, read), true);
assert.equal(access.covers(viewer, { ...target, business: "other" }, NOW, read), false);
assert.equal(access.covers(viewer, { ...target, project: "7c1e2d3f-0a9b-4c8d-8e7f-6a5b4c3d2e1f" }, NOW, read), false);
assert.equal(access.covers(viewer, { ...target, instance: "writer" }, NOW, read), false);
assert.equal(access.covers(viewer, target, new Date("2026-10-21T00:00:01Z"), read), false);
});
+37 -17
View File
@@ -240,41 +240,61 @@ test("loadAccess refuses bad JSON and a bad document with exit 2", () => {
const NOW = new Date("2026-10-20T12:00:00Z");
const target = (over = {}) => ({ business: "creative-hub", project: P1, instance: "researcher", ...over });
// Any level reads: the least level is viewer (row 193a).
const READ = { level: "viewer" };
test("covers: * and listed ids match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target({ project: P2 }), NOW), true);
assert.equal(covers(g2, target({ instance: "writer" }), NOW), true);
assert.equal(covers(g2, target(), NOW), true);
assert.equal(covers(g1, target({ project: P2 }), NOW, READ), true);
assert.equal(covers(g2, target({ instance: "writer" }), NOW, READ), true);
assert.equal(covers(g2, target(), NOW, READ), true);
});
test("covers: an unlisted project, instance or business doesn't match", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g2, target({ project: P2 }), NOW), false);
assert.equal(covers(g1, target({ instance: "writer" }), NOW), false);
assert.equal(covers(g2, target({ business: "acme" }), NOW), false);
assert.equal(covers(g1, target({ business: "acme" }), NOW), false);
assert.equal(covers(g2, target({ project: P2 }), NOW, READ), false);
assert.equal(covers(g1, target({ instance: "writer" }), NOW, READ), false);
assert.equal(covers(g2, target({ business: "acme" }), NOW, READ), false);
assert.equal(covers(g1, target({ business: "acme" }), NOW, READ), false);
});
test("covers: an expired grant doesn't match, at and after the instant", () => {
const [g1, g2] = checkAccess(doc(), context()).grants;
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z")), true);
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z")), false);
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z")), false);
assert.equal(covers(g1, target(), new Date("2026-12-30T23:59:59.999Z"), READ), true);
assert.equal(covers(g1, target(), new Date("2026-12-31T00:00:00Z"), READ), false);
assert.equal(covers(g1, target(), new Date("2027-01-01T00:00:00Z"), READ), false);
// No expires never expires.
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z")), true);
assert.equal(covers(g2, target(), new Date("2099-01-01T00:00:00Z"), READ), true);
});
test("covers: a level filter rejects another level, and no filter accepts any", () => {
test("covers: level is the least level asked for (row 193a)", () => {
const g = checkAccess(doc(), context()).grants[0];
const v = { ...g, level: "viewer" };
assert.equal(covers(g, target(), NOW, { level: "collaborator" }), true);
assert.equal(covers(g, target(), NOW, {}), true);
assert.equal(covers(g, target(), NOW, { level: "viewer" }), false);
assert.equal(covers({ ...g, level: "viewer" }, target(), NOW, { level: "collaborator" }), false);
assert.equal(covers(g, target(), NOW, READ), true);
assert.equal(covers(v, target(), NOW, READ), true);
assert.equal(covers(v, target(), NOW, { level: "collaborator" }), false);
});
test("covers: a missing or unknown level throws (row 193a, N1)", () => {
const v = { ...checkAccess(doc(), context()).grants[0], level: "viewer" };
assert.throws(() => covers(v, target(), NOW), TypeError);
assert.throws(() => covers(v, target(), NOW, {}), TypeError);
assert.throws(() => covers(v, target(), NOW, { level: undefined }), TypeError);
for (const bad of ["owner", "Viewer", "", null, 1]) {
assert.throws(() => covers(v, target(), NOW, { level: bad }), /covers needs a level, one of collaborator, viewer/);
}
});
test("covers: a grant with an unknown level covers nothing (row 193a)", () => {
const g = checkAccess(doc(), context()).grants[0];
for (const bad of ["owner", "Collaborator", undefined]) {
assert.equal(covers({ ...g, level: bad }, target(), NOW, READ), false);
}
});
test("covers: now must be a valid Date", () => {
const g = checkAccess(doc(), context()).grants[0];
assert.throws(() => covers(g, target(), Date.now()), TypeError);
assert.throws(() => covers(g, target(), new Date("nope")), TypeError);
assert.throws(() => covers(g, target(), Date.now(), READ), TypeError);
assert.throws(() => covers(g, target(), new Date("nope"), READ), TypeError);
});