Skip to content

S5 — an owner and a start date on the case, and the task board retired - #84

Merged
matthewpmunger merged 3 commits into
mainfrom
chunk-s5
Aug 27, 2026
Merged

S5 — an owner and a start date on the case, and the task board retired#84
matthewpmunger merged 3 commits into
mainfrom
chunk-s5

Conversation

@matthewpmunger

@matthewpmunger matthewpmunger commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Option 7a, as locked: two fields on the case plus Copy as ticket. No plan object, no task object.

Depends on F1, F2, S2 and W1. Rebased onto 728eb6c after F4 landed — see "Adapted to F4" and "Main is red before this branch" below.

Two fields, and no hand on either

owner and startedAt on IssueCase. Both are written inside applyAction, beside the checkpoints mark_fixed schedules, rather than in the start wrapper — which is what makes "no other path writes either" structural instead of a convention. start is a one-line call into applyAction, so there is no second door into in_progress that reaches the state without the stamp, and the registry allows start only from todo, so neither field is overwritten by a later move.

Neither is editable by hand. An owner a person can retype is an owner that disagrees with the history entry beside it the first time somebody corrects a spelling, and then the case has two answers to "who has this" — the same defect, one field smaller, that the four merged lifecycles were.

checklist is deleted. notes is free text: no schema, no required fields, nothing derived from it.

Adapted to F4

F4 replaced TransitionOptions.actor: string with by: Caller, which is the field the stamp reads. The owner is now attributionOf(options.by) — the identity, not the caller.

That is deliberate rather than mechanical. F4's point is that the class is a permission and the identity is the record of who; start is person-only in the registry, so the class here is already known and constant, and a field typed to admit { kind: "system" } is a field that eventually holds one. Going through attributionOf rather than reading by.userId directly also means the owner field and the attribution column in history are one answer to one question (rule 20) — and that the two identities F4 declines to render are exactly the two this declines to store: a system caller, and the migrated person carrying UNKNOWN_USER whom nobody can name. Both leave owner absent, and the row then says there is no owner rather than printing a class or a sentinel as though it were a person.

The system arm is unreachable today. It is handled anyway because attributionOf is what decides, not a belief here about who is permitted — widening action.start.actor must not silently start writing "grouping" into an owner field.

The Fix queue

FixQueue draws itself, the way WatchQueue already did. Decide and Show all are lists of undecided things, so they keep remediation grouping, the savings fold and the sort control; Fix and Watch hold decided things, and a fold is a triage affordance with no triage left to do.

Group order is read off queue.fix.holds, which already states ["todo", "in_progress"] — so To do sits above In progress without a second list to keep in step. Impact-ordered within each group via byWorstMeasured, unmeasured findings moved as a block rather than sorted by their zero (rule 18).

In progress rows carry the owner's initials, name and start date. Past thirty days that date renders amber, and that is the whole of it: text colour only, no wash, no border, no icon, no escalation, no email, no second threshold. The queue states this at the top, because a reader who has been told there is no escalation can leave a case for thirty-one days on purpose, and one who suspects there might be manages the queue instead of the work.

Copy as ticket

Plain markdown on the clipboard, offered on the two states queueHoldsState("fix", …) returns — and that is the entire integration surface. A tracker connection would be a second home for the work's state, which is where it disagrees with the first; markdown holds no state, so it cannot drift.

The saving comes from formatCaseImpact, which is now also the call case-detail.tsx makes. The ticket's figure is the case's figure to the byte because there is one string rather than two that agree. An unmeasured finding says "Not measured" and never 0. The link is casePath/issues/{id}, with no /issues/case/ segment to spell wrong.

Deleted

  • src/app/(app)/tasks/ — C1a already made the redirect redundant.
  • src/app/api/agent-audits/verify/ and its client caller, plus requestAgentAuditVerify. Confirmed present at the base commit with git cat-file -e f37b156:…; last touched by 80193a0. W1's checkpoint evaluator replaces it.
  • The "Add to tasks" / "Add workaround to tasks" affordance (triageActionLabel) and the dead Tasks-board plumbing behind it in store.tsxsaveTask, triageRec, advanceTask, verifyAgentIssueTask. The server-side mutations.advanceTask and /api/recs stay; they are the legacy store's write path, not affordances, and are still covered by atomic-store.test.ts.
  • Due-date and checklist fields. There was no due-date field to remove and no UI on either.

Two refactors S5 forced

EFFORT_LABEL moved from issue-row.tsx to impact-format.ts, because Copy as ticket is a third reader that is not a component and a lib should not import a .tsx to learn that hours is spelled "Hours". enteredAt generalised out of fixedAtOf, because the fix queue asks the same question about todo. Both re-export from their old homes, so existing importers keep one name.

Copy

Six new strings in lib/fix-copy.ts. Four of the ten locked strings are deliberately not restated: To do, In progress, Start and Mark fixed are already WORK_STATE_LABEL and ISSUE_ACTION_LABEL, and a second literal would let a heading disagree with the chip on the row beneath it (rule 20). Same call watch-copy.ts made about the four checkpoint outcome labels.

fix.no_owner — "No owner recorded" — is new and now locked. A legacy record that reached in_progress through stateFromTaskStatus never passed through start, and a migrated person caller carries UNKNOWN_USER; neither can be named, and fabricating one would be the rule 18 failure applied to a name instead of a reading.

Known, and deliberate

TaskStatus / Rec.taskStatus remains. It is a persisted field that fromRec reads to derive every case, alongside the collector, seed, watcher, normalize and the markers API — deleting it is a storage migration, not a rename. issue-case.ts documents the exit condition: it goes when the last reader is gone, and the last reader is fromRec. No route or component named task remains, and a test enforces that. Tracked as the open item against S5.

guide.ts still says "Tasks". The registry's allowlist assigns it to S9 — "the glossary retires; its entries define the retired terms on purpose."

Tests

lib/__tests__/fix-work.test.ts, 24 tests, written to rule 21 — assert the decision, not the code:

  • Registry-derived where the registry decides: group order against queue.fix.holds, group headings against work_state[].label, the "cannot fire twice" guarantee against action.start.from, the dismiss reason against DISMISS_REASONS, and the caller class for each transition constructed from that transition's own permission set rather than assumed.
  • Two-halves-against-each-other where S5 decides: the thirty in the sentence the reader sees against the thirty startedLongAgo turns on; the ticket's saving against formatCaseImpact's output and a check that both readers reach it through that one function.
  • The locked strings are not asserted against themselves. A literal here matching a literal in fix-copy.ts is a mirror against a mirror.
  • Source-scoped guards: every registry transition except start is applied from every legal state and must leave both fields untouched; only issue-case.ts assigns owner; the startedAt scan is narrowed to files importing from issue-case, since collection jobs and projects have a startedAt of their own and a broad scan would be loud enough to get switched off.

Main is red before this branch

728eb6c does not build or test green on its own, and this PR inherits that. F4 (#82) branched before S3/S6/S7 (#81) and was merged without a rebase, so it renamed TransitionOptions.actorby: Caller and HistoryEntry.actorby without seeing the S6/S7 tests that pass actor. Git saw no conflict because the files are disjoint.

Measured on 728eb6c with nothing of mine applied:

  • tsc --noEmit: 8 errors — digest-arrival.test.ts ×4, digest.test.ts ×3, webhook.test.ts ×1
  • vitest run: 4 files / 4 tests failing — the three above, plus F4's own caller.test.ts, whose "no identity string is compared against a permission list" guard flags the pre-existing personActionsFor in issue-case.ts

On this branch, with S5 applied: the same 4 files fail, the same 8 tsc errors appear, and nothing else. 1194 tests pass against main's 1170 — the +24 are this chunk's. npm run lint and npm run build are clean here.

I have not fixed any of it. It is F4's blast radius crossed with S7's, it wants its own commit, and folding it into S5 would hide a real merge-order defect inside an unrelated chunk. Happy to take it as a separate PR.

Verification

npm run lint clean, npm run build clean, npx --yes npm@10.9.7 ci --dry-run --ignore-scripts --no-audit --no-fund per AGENTS.md — lockfile untouched, nested esbuild records intact. Typecheck and tests as measured above.

Also exercised end to end against the real seed rather than only in unit assertions: start stamps both fields, the amber flips at exactly 30 days, the ticket's impact string matches the case's, an unmeasured finding's ticket reads "Not measured", and the one legacy in-progress case renders fix.no_owner.

Build output confirms /tasks and /api/agent-audits/verify are gone from the route table.

…board (S5)

Option 7a: two fields on the case, plus Copy as ticket. No plan object and
no task object.

`start` stamps `owner` and `startedAt` and nothing else writes either. Both
are written inside `applyAction`, beside the checkpoints `mark_fixed`
schedules, so there is no second door into `in_progress` that could reach the
state without the stamp — and the registry allows `start` only from `todo`,
so neither field can be overwritten by a later move. Neither is editable by
hand: a field that can drift from the history entry beside it is a second
answer to "who has this", which is the defect the case object exists to
remove. `checklist` is gone; `notes` is free text with no schema.

The owner is the identity `attributionOf` gives the caller, not the caller.
F4 split the two on purpose — the class is a permission and this is the
record of who — and `start` is person-only, so the class here is known and
constant; a field typed to admit `{ kind: "system" }` is a field that
eventually holds one. Going through `attributionOf` also means the owner and
the attribution column in history are one answer to one question (rule 20),
and that the two identities F4 declines to render are the two this declines
to store: a system caller, and the migrated person nobody can name. Both
leave the field absent, and the row then says so.

The Fix queue draws itself, like Watch. To do above In progress, in the order
`queue.fix.holds` already states, impact-ordered within each group with
unmeasured findings last (rule 18). In progress rows carry the owner's
initials, name and start date; past thirty days that date renders amber and
that is the whole of it — no escalation, no email, no second threshold. The
queue says so at the top, because a reader who has been told there is no
escalation can leave a case for thirty-one days on purpose.

A legacy record that arrives already in progress never passed through
`start`, so it has no owner. The row says so rather than inventing one.

Copy as ticket is plain markdown on the clipboard and the entire integration
surface. A tracker connection would be a second home for the work's state,
and a second home is where it disagrees with the first. The saving comes from
`formatCaseImpact`, which is the call the case detail makes, so the ticket's
figure is the case's figure to the byte; an unmeasured finding says "Not
measured" and never 0. The link is `casePath` — `/issues/{id}`, no
`/issues/case/` segment to get wrong.

Deleted: the `/tasks` redirect stub (C1a made it redundant), the standalone
`api/agent-audits/verify` endpoint and its client — W1's checkpoint evaluator
replaces it — and the "Add to tasks" / "Add workaround to tasks" affordance
with the dead Tasks-board plumbing behind it.

`EFFORT_LABEL` moved to `impact-format.ts` and `enteredAt` generalised out of
`fixedAtOf`, both because S5 gave them a third reader that is not a component.
Three test files still passed the retired actor option where
TransitionOptions now takes by: Caller; each gains the PERSON constant
the migrated suites already use. personActionsFor still tested the
permission list against a bare word, which the guard in caller.test.ts
flags — port main's actorPermits helper verbatim so both it and
applyAction read the table the same way, and so the eventual merge is
clean.
# Conflicts:
#	src/app/(app)/pages/pages-content.tsx
#	src/components/case-detail.tsx
#	src/lib/__tests__/digest-arrival.test.ts
#	src/lib/__tests__/webhook.test.ts
#	src/lib/issue-case.ts
@matthewpmunger
matthewpmunger merged commit f52362c into main Aug 27, 2026
6 checks passed
@matthewpmunger
matthewpmunger deleted the chunk-s5 branch August 27, 2026 14:42
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