Repository navigation
Answer the unregistered inbox with a foundation code, not a sentence - #289
Merged
pixexid merged 3 commits intoAug 19, 2026
Merged
Conversation
pixexid
added a commit
that referenced
this pull request
Aug 19, 2026
Review P2 on #289. Moving the refusal into the reader's result meant the CLI had to refuse on its own account, and that new line had no test — deleting it left the suite identically green. The mutant returned exit 0 and `[]` for an unregistered project: #280's own defect class, a refused read presenting as an empty inbox, on the surface where a seat reads its durable inbox. The behaviour was already correct; nothing proved it stayed correct. The test asserts both halves — a registered project lists exit 0 as a bare array with the seeded row in it, an unregistered one exits 2 with a refusal — because a green on the refusal alone is also compatible with the verb not existing, the flag being ignored, or the seeding never having run. Deleting the refusal fails it (expected +0 to be 2); making the CLI refuse unconditionally fails it too. Filtered: 1 failed | 175 skipped (176). Also review P3, same line: `message` is optional on the result, so `new Error(listed.message)` could construct an empty error. Returning invalidCli directly with the outcome code as fallback drops the throw and catch round trip in the same block and gives a future messageless outcome something to print.
Closes #280 `operatorMessages` now answers `{ outcome, message?, messages }`, where `outcome` is drawn from the repo's existing `FoundationCode` vocabulary and `satisfies readonly FoundationCode[]` is the standing proof that no parallel error scheme was invented at the app boundary. The condition is the one foundation already refuses as PROJECT_CONFIG_REQUIRED: no row in `project_config_heads`. It travels in the result rather than on a thrown error because the SDK reduces every handler rejection to `{ code: "handler_error", message }`, so a code carried on the error would not survive the boundary. Both consumers migrate. The panel branches on the code; the unread poll stops branching at all, because an answered read is a proven count and the unregistered outcome carries no rows. The exact registered-project check, its timing, and the CLI's output and refusal are unchanged.
Review P2 on #289. Moving the refusal into the reader's result meant the CLI had to refuse on its own account, and that new line had no test — deleting it left the suite identically green. The mutant returned exit 0 and `[]` for an unregistered project: #280's own defect class, a refused read presenting as an empty inbox, on the surface where a seat reads its durable inbox. The behaviour was already correct; nothing proved it stayed correct. The test asserts both halves — a registered project lists exit 0 as a bare array with the seeded row in it, an unregistered one exits 2 with a refusal — because a green on the refusal alone is also compatible with the verb not existing, the flag being ignored, or the seeding never having run. Deleting the refusal fails it (expected +0 to be 2); making the CLI refuse unconditionally fails it too. Filtered: 1 failed | 175 skipped (176). Also review P3, same line: `message` is optional on the result, so `new Error(listed.message)` could construct an empty error. Returning invalidCli directly with the outcome code as fallback drops the throw and catch round trip in the same block and gives a future messageless outcome something to print.
Rebase resolution onto 42fadcf. #288 landed withSenderTitles and sender-title resolution on the same function this PR reshaped, so three files conflicted. server.ts and tests/server.test.ts were resolved by hand. listOperatorMessages keeps #288's async signature, `bb` parameter and default-off withSenderTitles, and gains the outcome envelope around it; the unregistered early return sits before the title resolution, which is correct because an unregistered project has no rows to title. dist/server.js was regenerated by the builder, never hand-resolved. Two #288 tests merged without conflict but still assumed the bare array: the RPC assertion in "keeps inbox messages readable when a sender title cannot be resolved", and a sidebar mock, which now goes through the existing okMessages wrapper like its siblings. #288's own property is preserved and still asserted: the plain list performs no threads.get, only withSenderTitles does, and the unread poll still does not request titles.
pixexid
force-pushed
the
bb/gh-280-typed-unregistered-inbox-refusal-thr_2rbixgj33q
branch
from
August 19, 2026 03:30
6a28e1f to
2d43308
Compare
pixexid
marked this pull request as ready for review
August 19, 2026 03:41
pixexid
deleted the
bb/gh-280-typed-unregistered-inbox-refusal-thr_2rbixgj33q
branch
August 19, 2026 03:48
This was referenced Aug 19, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #280
Acceptance: complete
Review tier: A
operatorMessagessignalled "this project has no bb-collab inbox" by throwing a bareErrorwhose sentence the app then string-matched. Since #281 that sentence had two consumers — the panel's error branch and the unread poll's zero-vs-failure decision — so a reword or a localisation would silently have turned a failed read into a proven zero.The shape, and why
The repo already has a refusal type with codes in
src/foundation.ts, and those codes reach the app in the result (FoundationResult.outcome), never on a thrown error. That is not a stylistic choice: the SDK reduces every handler rejection to{ code: "handler_error", message: errorMessage(error) }, so structure on the error is destroyed at the boundary. The output value is the only channel a code survives.So
operatorMessagesreturns{ outcome, message?, messages }:outcomeis"OK"or"PROJECT_CONFIG_REQUIRED", both already inFoundationCode.as const satisfies readonly FoundationCode[]is the compile-time proof that this is the existing vocabulary and not a parallel scheme.PROJECT_CONFIG_REQUIREDis exactly what foundation already refuses for a missingproject_config_headsrow — the same query this check runs.messageis optional and stays for humans. Nothing branches on it.Both consumers migrated
result.value.outcome. Aggregate mode skips; scoped mode still shows the refusal, now naming the code.Proof
tests/sidebar.test.tsx:branches on the outcome code alone, so rewording the human sentence moves nothing— the required test. Same outcome with the old sentence, a French one, and none at all; identical behaviour in aggregate and scoped mode each time. Read in scoped mode deliberately: in aggregate mode "skipped" and "loaded zero rows" are indistinguishable, so an aggregate-only assertion would not have discriminated.treats a rejection carrying the old sentence as the failed read it is— the inverse. The exact sentence now buys nothing.binds the skip to the outcome code the server itself produces— the result is taken from the running server, not authored in the test.Mutation-checked, each failing only its own target:
result.messageinstead ofoutcomebranches on the outcome code alone(1 of 48)treats a rejection…,keeps a failure that merely quotes…(2 of 48)tests/inbox-nav-indicator.test.tsxgainscounts the unregistered outcome as a proven zero rather than retaining the last count; the #281 retained-count test is unchanged and still passes.Unchanged on purpose
What is refused and when:
markOperatorMessageReadandreplyToOperatorMessagestill throw throughrequireRegisteredInboxProject, and the CLI still errors on an unregistered project and still prints a bare array. The exact registered-project match onoperatorMessagesis untouched — the reader still returns no rows for a project with no config head.Other message-string branching
Swept the repo.
app.tsx:49was the only production site; the three remainingmessage ===matches are log-entry assertions intests/server.test.ts, not control flow. No widening.npm run verifyexit 0 — 370 passed, 2 skipped, 22 files passed / 2 skipped.dist/regenerated by the builder;dist/*.meta.jsonnot committed.Review round 2 — P2 remedy
server.tsnow returnsinvalidCli(...)for a non-OK outcome instead of throwing one, andtests/server.test.tscovers the CLI list path, which had no test at all.The mutant the review found — deleting that refusal — made
bb collab inbox --project <unregistered>return exit 0 and[]. That is #280's own defect class on the agent-facing surface. The behaviour was correct; nothing proved it stayed correct.1 passed | 176 skipped (177)177 passed (177)1 failed | 176 skipped (177)—expected +0 to be 21 failed | 176 passed (177)1 failed | 176 skipped (177)—expected 2 to be +01 failed | 176 passed (177)Re-run at the rebased head, not carried across from
6a28e1f.1 + 176 = 177in every row, so the filter hit exactly one test; the unfiltered runs show no other test moved either way.Both halves are asserted: a registered project lists exit 0 as a bare array with the seeded row in it, an unregistered one exits 2 with a refusal. The positive control is not decoration — a green on the refusal alone is compatible with the verb not existing, the flag being ignored, or the seeding never having run.
P3 (message): taken.
messageis optional on the result, sonew Error(listed.message)could construct an empty error;invalidCli(listed.message ?? listed.outcome)drops the throw/catch round trip in the same block and gives a future messageless outcome something to print.P3 (mutation table): taken — the earlier table now names the substring form.
Rebase onto 42fadcf
origin/mainmoved twice under review —91d5d65→9dccb7a(#293) →42fadcf(#288). #288 touched the same inbox surfaces, soserver.ts,tests/server.test.tsanddist/server.jsconflicted.server.tsandtests/server.test.tswere merged by hand. The merged reader takes #288'swithSenderTitlesparameter and returns #280's envelope, with the unregistered early return before title resolution — an unregistered project has no rows to title.dist/server.jswas regenerated by the builder, never hand-edited.Two #288 tests auto-merged without conflict but still assumed the bare-array shape, and had to be reshaped onto
{ outcome, messages }:keeps inbox messages readable when a sender title cannot be resolved(tests/server.test.ts) andfalls back to the secondary sender id when its live title is unavailable(tests/sidebar.test.tsx, routed through the existingokMessageshelper). Both are why the evidence was re-run rather than carried forward.#288's own property holds: the plain list still performs no
threads.get, onlywithSenderTitles: truedoes, and the unread poll never asks for titles.npm run verifyexit 0 — 373 passed, 2 skipped, 22 files passed / 2 skipped.