Skip to content

R2 — make main green again after F4 landed on S3/S6/S7 (Blocking) - #86

Merged
matthewpmunger merged 1 commit into
mainfrom
chunk-r2
Aug 26, 2026
Merged

R2 — make main green again after F4 landed on S3/S6/S7 (Blocking)#86
matthewpmunger merged 1 commit into
mainfrom
chunk-r2

Conversation

@matthewpmunger

Copy link
Copy Markdown
Collaborator

Blocking. main at 728eb6c does not typecheck and does not test green. This restores it. No production code changes — four test files.

What happened

F4 (#82) branched before S3/S6/S7 (#81) and was merged without a rebase. F4 renamed TransitionOptions.actorby: Caller and HistoryEntry.actorby; it never saw the S6/S7 test files that pass actor. The files are disjoint, so git reported no conflict and merged cleanly into a broken tree.

Measured on 728eb6c in a clean worktree:

before after
npm run typecheck 8 errors clean
npm test -- --run 4 files / 4 tests failing 97 files / 1210 tests passing
npm run lint clean clean
npm run build clean clean

Every branch cut since inherits the failures. That is the part worth fixing quickly — a red suite is how everyone learns to stop reading the output.

Three files: the rename F4 would have made

digest.test.ts, digest-arrival.test.ts and webhook.test.ts each get a local const PERSON: Caller, which is the convention F4 established in the four test files it did update (issue-case, checkpoint-evaluation, case-applicability, watch-notation). I followed that rather than inventing a shared helper.

None of the three renders an attribution — the digest is about cases, not about who moved them — so each PERSON carries a line saying it exists only to satisfy the transition guard and is never asserted on. That is worth writing down, because the next reader will otherwise wonder whether the identity matters to the assertion.

The fourth: F4's guard, not personActionsFor

F4's caller.test.ts asserts that no identity string is ever compared against a permission list. It was flagging personActionsFor in issue-case.ts:

return actionsFor(state).filter((action) => ISSUE_TRANSITIONS[action].actor.includes("person"));

That is the legitimate use, and it follows from F4's own reasoning. transition.actor is a permission set — a list of classes. Two things may be tested against it, and both are classes:

  • a caller's kind, which is the runtime check applyAction performs;
  • a class named outright, which is how a caller asks a question about the permission set rather than about a caller. "Which actions may a person fire" is precisely what a permission set is for, and the answer decides which buttons exist.

What F4 set out to forbid is comparing an identity against the list — a user id, an agent name — because that guard answers for the strings it lists and has to guess for the rest, so an unlisted caller either gets in or is locked out for having a new name. The regex could not tell a class literal from an identity, so it caught the wrong one.

Rewriting personActionsFor to satisfy an over-broad guard would be making the code worse to keep a check green, which is the thing R1 spent a session forbidding. So the guard is narrowed instead: the argument must be a kind expression, or a class the registry declares.

The permitted literals are read off vocabulary.json via the REGISTRY_CLASSES the file already computes — so a third class is permitted the moment it is declared, and an agent name (checkpoint, grouping, migration) never is. "system" is permitted, because "may the system fire this" is the same legitimate question as "may a person".

Narrowing a check earns the obligation to show it still bites

The rule is now a named predicate with its own test rather than an inline regex condition. kind expressions pass, every registry-declared class passes, and identities of both shapes — quoted agent names, quoted emails, bare variables like rec.owner and entry.by.userId — are rejected.

Without that test, a loosened check is indistinguishable from a deleted one.

A class held in a variable is still flagged. That is deliberate rather than an oversight: it is indistinguishable from an identity by reading, it is rare, and both call sites in this codebase have a better form available. Erring tight costs an author one line of justification; erring loose costs the guard.

One thing worth knowing about the guard

It is a text scan, so it reads comments as well as code and cannot tell the difference. My first draft spelled the pattern out in its own doc comment and the guard reported itself — a failure that looks exactly like a real one. There is now a comment in the file saying the pattern must not appear in prose. Cheap to hit, annoying to diagnose.

Verification

npm run lint, npm run typecheck, npm test -- --run (97 files, 1210 tests), npm run build — all clean. npx --yes npm@10.9.7 ci --dry-run --ignore-scripts --no-audit --no-fund per AGENTS.md; lockfile untouched.

Merge order

This is based directly on main and touches only test files. S5 (#84) is based on main too and its write set does not overlap — it changes no test file this one touches. Either order works; landing R2 first means S5's CI reports only S5.

F4 (#82) branched before S3/S6/S7 (#81) and was merged without a rebase. It
renamed `TransitionOptions.actor` to `by: Caller` and `HistoryEntry.actor` to
`by`, and never saw the S6/S7 tests that pass `actor`. The files are disjoint,
so git reported no conflict and `main` went red on merge: 8 typecheck errors
and 4 failing test files. Every branch cut since has inherited them, which is
worse than the four tests — a red suite is how everyone learns to stop reading
the output.

Three files are the rename F4 would have made had it seen them.
`digest.test.ts`, `digest-arrival.test.ts` and `webhook.test.ts` each declare a
local `PERSON: Caller`, which is the convention F4 established in the four test
files it did update. None of the three renders an attribution — the digest is
about cases, not about who moved them — so each says in a line that the caller
exists to satisfy the transition guard and is never asserted on.

The fourth was F4's own guard failing on `personActionsFor`, and that one is
the guard's defect rather than the function's.

`transition.actor` is a permission set: a list of classes. Two things may
legitimately be tested against it and both are classes — a caller's `kind`,
which is the check `applyAction` performs, and a class named outright, which is
how a caller asks a question about the permission set rather than about a
caller. `personActionsFor` does the second: "which actions may a person fire"
is exactly what a permission set is for, and the answer decides which buttons
exist. What F4 set out to forbid is comparing an IDENTITY against the list — a
user id, an agent name — because that guard answers for the strings it lists
and guesses for the rest.

The regex could not tell the two apart, so it flagged the legitimate use.
Rewriting `personActionsFor` to satisfy it would have been making the code
worse to keep a check green. The guard is narrowed instead: the argument must
be a `kind` or a class the registry declares. The permitted literals are read
off `vocabulary.json`, so a third class is permitted the moment it is declared
and an agent name — `checkpoint`, `grouping`, `migration` — never is.

Narrowing a check earns the obligation to show it still bites, so the rule is
now a named predicate with its own test: `kind` expressions and every declared
class pass, and identities of both shapes are rejected. A class held in a
variable is still flagged, deliberately — it is indistinguishable from an
identity by reading, and both call sites here have a better form available.

The scanner reads prose as well as code, so the file may not spell the pattern
it looks for. It did, briefly, and reported itself; there is now a comment
saying why it must not.

No production code changed.
@matthewpmunger
matthewpmunger merged commit 96964f9 into main Aug 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant