Treat an unclear SHADOW_MODE as on, instead of posting for real - #233
Treat an unclear SHADOW_MODE as on, instead of posting for real#233jerelvelarde wants to merge 3 commits into
Conversation
CPK-7929 2. #148 + #157 — record what was posted, not the draft (blocked by PR #191)
Both live in #148 — the DB records the draft, not what was posted
Note that #191 introduces a delivery state machine ( #157 — three correctness gaps in the same handler
The third is the urgent one and is arguably safety rather than quality. #191's description claims the null-description concatenation is already fixed there — verify against the merged diff before re-fixing it. UNBLOCKED 2026-08-19 — PR #191 merged at 17:45Z, so the Start with the check this ticket already flagged rather than designing fresh: #191 introduced the Same for #157: #191's description claimed the null-description-stringifies-as- Note that #150 also still touches |
jerelvelarde
left a comment
There was a problem hiding this comment.
Re-verified against current main rather than as-opened, since this has been sitting for 19 days and is now 28 commits behind. GitHub will not let me approve my own PR, so this is a status comment — it still needs a reviewer, and it is the only thing in the queue that is a posting-safety switch.
It has not rotted. Merges cleanly onto current main with no conflicts, including the ai-response.ts import line the merge notes warned about. Full package suite on the merge result: 1222 tests, 66 files, all passing — against 1198/65 on main, so the delta is exactly the new file and nothing changed status. shadow-mode.test.ts is 24 of those.
One correction to my own PR body. It claims reverting isShadowMode to === 'true' fails 12 tests. Ran it: 15 fail, all in shadow-mode.test.ts. Understated rather than overstated, but the count is wrong and I have been holding other PRs to theirs.
What a reviewer should push on, so this is not a rubber stamp:
- The asymmetry is the whole design and it is not free: a typo in the value now silently stops the bot answering, and you find out by reading logs. The PR argues that trade is right way round. That is the thing to agree or disagree with; everything else is mechanical.
- Unset still means off, deliberately — worth confirming that is what you want, because it means a fresh deployment posts for real unless someone sets the flag.
- The two test suites that mock
@copilotkit/outpost/sharedwholesale now carry anisShadowModeentry that delegates to the same env check rather than returning a constant. That is duplicated logic in scaffolding, and the alternative (a hardcodedfalse) would have left those files' existingSHADOW_MODEtests asserting nothing. Worth a look.
Still not covered, unchanged: no startup assertion and no dashboard surface, so an operator confirms the live mode by reading worker logs. That is the real remaining gap and it wants its own change.
@NathanTarbert — this is the one I would put ahead of #248 in your queue if you have a slot. It is 9 files, 156 lines, verified green on current main, and it is the switch between a parallel-run window and generated text landing in a stranger's support thread.
|
Hey @jerelvelarde, Read through this one properly and ran it rather than reading it. The asymmetry call is right, and the docblock in Three things I'd want before this lands, then some smaller ones. The gates the fix protects aren't actually covered. I reverted The cutover script is a fourth read path, and this change makes it disagree with the workers. The Smaller ones:
I also grepped for a fifth production read path and there isn't one — the three you convert plus the cutover script is the whole set. And the barrel addition is fine against the browser-safety convention in Happy to be wrong on the empty-string one if you'd rather treat it as absence deliberately. The mock and cutover items are the two I'd call blocking. |
NathanTarbert
left a comment
There was a problem hiding this comment.
Jerel, this is the right fix and the right default. The asymmetry argument in the description is the part I'd want kept verbatim somewhere permanent: naming what each failure direction actually costs, rather than asserting that one is "safer", is what makes the unusual default defensible six months from now. The unset carve-out is also correct, and I'm glad you argued it rather than quietly picking one.
A few things before it goes in. Most of them are the same shape, which is worth saying up front: the predicate is now right, but very little outside shadow-mode.test.ts would notice if it stopped being right.
The three that need doing
docker-compose.override.yml looks like local scaffolding that rode along. Compose auto-loads that exact filename, and ports: !override replaces the base list instead of adding to it, so docker-compose.yml's 5432:5432 goes away and postgres publishes only 5437. The README's setup path (docker compose up -d → cp .env.example .env → pnpm db:push) points at localhost:5432, so a fresh clone of this branch gets connection refused. Worth deleting from the diff and adding to .gitignore while we're here, since the override file is per-developer by convention and the next one would otherwise get committed too. (!override also wants Compose ≥ 2.24.4, and the README lists the prerequisite as just "Docker".)
The two vi.mock factories hand-roll the predicate. This is the one I'd most want changed, because the diff removes three copies of the comparison and the mocks add two back — and they've already drifted, with no EXPLICITLY_ON and no warn. The comments describe them as delegating to the real env check, which is the behaviour we want; a partial mock gets you that literally:
vi.mock('@copilotkit/outpost/shared', async (importOriginal) => ({
...(await importOriginal<typeof import('@copilotkit/outpost/shared')>()),
computeFunnelMetrics: vi.fn().mockReturnValue({ /* unchanged fixture */ }),
}));Not a guess about whether it works here — apps/discord-bot/src/__tests__/shadow-mode.test.ts:13-19 already does it against this same specifier, as do apps/web/src/__tests__/postmark-webhook.test.ts:45 and two others, and packages/outpost/vitest.config.ts:14 aliases the specifier to source so nothing needs building first. I tried it on onboarding-digest.test.ts keeping only computeFunnelMetrics stubbed and it came out 15/15. ai-response.test.ts's mock is bigger, but the spread makes the override list strictly narrower — its AI_CONFIDENCE and MAX_JOB_ATTEMPTS stubs look like they already match the real constants, worth confirming before dropping them.
The fix isn't fenced at the call sites yet. Every shadow test in both queue suites sets SHADOW_MODE='true', which is the one value that behaves identically before and after this change. I reverted shadow-mode.ts to === 'true' locally to see what would catch it: 121 of 145 tests stayed green, and the 24 that failed were all in the new shared file. So ai-response.ts:824 could go back to a raw comparison tomorrow and CI would pass.
One case per handler with a non-canonical spelling closes it, and it falls out of the mock change for free (right now such a test would exercise the mock's private copy, not the shipped function):
it.each(['1', 'TRUE', 'yes', 'on', ' true '])(
'does not post to Discord when SHADOW_MODE=%j',
async (value) => { /* … */ },
);ai-response.ts is the higher-value one of the two, since that's the path that reaches real Discord and GitHub surfaces.
Two smaller ones in the same family
EXPLICITLY_ON isn't pinned by anything. I shrank it to new Set(['true']) and all 24 tests still passed, because a dropped member still comes back true through the fail-closed branch and the ON it.each only asserts the boolean. Adding expect(warn).not.toHaveBeenCalled() to that block makes the mutant fail, and it happens to pin the normalization at the same time — right now .trim() and .toLowerCase() are each held down by exactly one OFF case, so most of the ON list ( 'TRUE', 'tRuE', ' true ', 'YES', 'ON') passes for the wrong reason.
And there's a fourth reader the sweep missed, which changes the story slightly: scripts/cutover/execute-cutover.ts:171 still has its own currentValue !== 'true'. With SHADOW_MODE=TRUE the worker now correctly treats shadow mode as on, while the cutover script reports "Shadow mode already disabled (SHADOW_MODE=TRUE)" and passes the step. Same fail-open, moved to the cutover path. Either pull it onto the helper here or say so in the comment, because as written ("a shared one is the only version of this that stays fixed") a future maintainer won't go looking.
Things I'd like your read on rather than just changing
A boot log of the resolved mode. You call the missing startup assertion out yourself, and I agree it wants its own change, but there's a cheap piece of it that belongs here: right now a healthy worker gives an operator no way to answer "which mode am I in?" without waiting for a job and inferring it from which line got printed. apps/worker/src/index.ts:42-55 already establishes fail-fast-at-boot for the sync engine, so one console.log of the resolved mode next to it would fit the existing shape. The stronger version — throwing when the var is absent in production — is a real behaviour change and I'd rather discuss it than assume; the case for it is that staging is documented as SHADOW_MODE=true, so a variable that fails to carry over to a new replica is a silent fail-open with no output at all, which is both likelier and quieter than the TRUE case this PR fixes.
'' sitting in EXPLICITLY_OFF. A declared-but-cleared Railway variable, or a .env line with nothing after the =, currently reads as "post for real" with no warning. That's arguably against the module's own rule that anything set is an operator trying to say something. Dropping '' would send it to the unrecognized branch (on, plus the warn), and ' ' would follow. I can see the other side — nobody clears a value meaning "off", so maybe it's genuinely noise — happy to leave it if you'd rather.
The header's fail-closed claim. The module docblock states it unqualified, and the unset exception only appears in the function doc fifteen lines down. Someone skimming the header concludes that a missing variable is the safe case, which is the belief this module exists to kill. Scoping it to "values that ARE set fail closed" in the header would let the function doc shrink.
Before the check runs: the branch is 31 commits behind, and 1fedbc8 added the eslint and format checks to every PR after this one branched. Worth a rebase so those run against the current config. Relatedly, the two reformatted hunks in the discord-bot file aren't noise — I checked, and the base revision fails prettier --check while the branch passes, so touching that file makes them mandatory. Might be worth a line in the description so nobody spends time on them.
Separate, and not yours to fix here
Two things I found while checking whether the sweep was complete, both pre-existing. apps/teams-bot/src has no SHADOW_MODE reference anywhere, and handlers/message.ts:120 posts the acknowledgment card at ingest — so a staging Teams channel gets "an AI answer is coming" while the worker correctly withholds the answer. .env.example:41-42 says the flag "covers EVERY platform (GitHub, Slack, Teams)", which isn't true today. There are also three unreferenced postAiResponse helpers (apps/github-app/src/lib/github-poster.ts:36, slack-bot/src/lib/slack-poster.ts:16, teams-bot/src/lib/teams-poster.ts:26), each fully formed and one import away from bypassing the worker's gate. I'll open issues for both rather than grow this diff.
While the docs are open: docs/deployment.md:212 still says "check SHADOW_MODE before posting", which is the instruction that produced the three copies. Naming isShadowMode() there instead would be a one-line change worth folding in.
Checked and clean
For the record, so nobody re-does it: the subpath import resolves without any new dependency (apps/discord-bot already imported @copilotkit/outpost/shared before this PR, and turbo.json's build.dependsOn: ["^build"] covers the Dockerfile's prune-then-build). The barrel addition stays browser-safe — process.env is read lazily inside the function, and while 15 "use client" files in apps/web import that barrel, none calls this. The new test file is inside the tsc program and typechecks clean. And the env restore hygiene in the new test is the good version: capturing in beforeEach and delete-ing rather than assigning undefined is exactly the trap ai-response.test.ts:110-122 documents, and it's nice to see it not re-trodden.
One genuinely good thing worth calling out: the test asserts on the warning itself, both that it fires once and that the message carries the offending value. Most diagnostics get added and never tested, which is how they quietly disappear a year later.
|
Filed the two out-of-scope items from the review so they're linkable rather than living in a comment:
Both are pre-existing and neither should grow this diff. The one doc line I'd still fold in here is |
Closes the third item in #157, the one that is safety rather than quality. Shadow mode exists so Outpost can run alongside an incumbent without double-posting at real reporters. Every call site tested it with `process.env.SHADOW_MODE === 'true'`, so `SHADOW_MODE=TRUE`, `=1` and `=yes` all read as "not shadow mode" and posted to Discord and GitHub for real. A safety flag failing OPEN on inputs an operator would reasonably expect to work. Now one shared `isShadowMode()` that fails closed: recognized off values are off, recognized on values are on, and anything else that is SET is treated as ON and logged with the value it rejected. The asymmetry is the point — a false positive costs a parallel-run window where nothing posts and someone notices from the logs; a false negative posts machine-generated text at real people under the flag meant to prevent that. Unset still means off. Shadow mode is opt-in, and defaulting an absent variable to ON would make a fresh deployment silently answer nobody. The fail-closed rule applies to values that are set: those are an operator trying to say something, and the safe reading of an unclear instruction is the one that posts nothing. Central rather than per-call-site, as #157 suggested. Three copies of this predicate existed and all three had the same bug, so the discord-bot one now re-exports the shared version instead of reimplementing it — three copies is why this survived. 24 tests, all nine previously-fail-open spellings among them. Reverting the body to `=== 'true'` fails 12. Package total 1075 → 1099, which is exactly the new file; discord-bot unchanged at 66. One thing worth knowing for the next reader: two suites mock `@copilotkit/outpost/shared` wholesale, so the mock had to grow an `isShadowMode` entry. It delegates to the same env check rather than returning a constant — a hardcoded `false` would have left those files' existing SHADOW_MODE-based tests asserting nothing. Refs #157.
#250 added a format check over the files a PR touches, and three of these were already unformatted on main. Cosmetic only — collapsed single-item type re-exports onto one line and wrapped long template literals. Verified: 1099 tests passing, unchanged from before.
…ing the predicate Nathan's review on #233. The predicate was right; almost nothing outside shadow-mode.test.ts would have noticed if it stopped being right. - Fence the call sites. Every shadow test in both queue suites used SHADOW_MODE='true', the one spelling that reads the same before and after this change, so reverting isShadowMode to === 'true' left both suites green. Adds an it.each over the spellings that used to post for real, to each handler. - Replace both hand-rolled vi.mock predicates with partial mocks. The diff deleted three copies of the comparison and the mocks added two back, already drifted (no EXPLICITLY_ON, no warn). calculateBackoff stays overridden: the real one adds Math.random() jitter. - Pin EXPLICITLY_ON via the absence of the warning. On the boolean alone, shrinking it to ['true'] left all 24 tests passing. - Route scripts/cutover/execute-cutover.ts through the helper. Fourth copy of the comparison; the sweep missed it. - '' no longer counts as off. A cleared variable is set, so it takes the fail-closed path and warns rather than silently posting for real. - Log the resolved mode at worker boot. Not a throw — that is a real behaviour change and belongs with the startup-assertion work. - Scope the header's fail-closed claim to values that ARE set. - Drop docker-compose.override.yml and gitignore it: ports: !override replaced the base list, so postgres stopped publishing 5432 and the README setup path failed on a fresh clone. - docs/deployment.md now says to gate on isShadowMode(), which is the instruction that produced the three copies. 1234 tests passing, up from 1222.
dbe8795 to
d4fdae0
Compare
|
All five of the actionable ones are in, plus my read on the three you asked about. Rebased onto current You were right that the shape was the problem rather than the predicate. The fix now has teeth at the call sites:
Baseline 1234 passing, up from 1222. The three
Both The fence. The two smaller
The fourth reader. Your three judgment callsBoot log — yes, took the cheap half. One line next to the sync-engine fail-fast, printing the resolved mode and the raw value when it is off. I left the throw out and said why in the comment: staging is documented as
Header claim — scoped. It now says every value that IS set fails closed, and states the absent-variable exception in the header rather than fifteen lines down, on your exact reasoning: someone skimming it and concluding a missing variable is safe has formed the belief the module exists to kill. Two notes on processThe reformatted hunks in the discord-bot file are mandatory rather than noise, as you found — I have added a line to the description. Same applies to a second prettier commit that is now folded into this one. And I did not run a cr-loop on this, because the session I did it in is configured without subagents. Everything above is verified by hand instead: four mutations from a committed baseline, full suite, typecheck on both Your teams-bot and |
|
This is in good shape. I re-ran your mutation table from a clean checkout of The One thing to fix before mergeThe cutover import does not resolve from
Measured with The reason CI is green is worth knowing on its own. Adding the dependency edge fixes it. I tried it rather than guessing: // package.json (root)
"dependencies": {
"@copilotkit/outpost": "workspace:*"
}pnpm honours the workspace protocol in the root manifest, which creates the link the script needs. Back to 4 files / 64 tests, and Wiring Small, and I would fold it in
One line in Two I would rather you decide onThe warning fires per call rather than per process. The boot log covers the worker but not the bot. Not this diffThe cutover reporting is worse than the comparison we fixed, and it predates this PR on both paths, so I have written it up as #261 rather than growing this one. Short version: The test gap goes with it: nothing in And the teams-bot and Verified while I was in hereSo nobody repeats it: the barrel export adds only Happy to take the root |
|
Re-ran the three blockers against Queue gates — closed. Both test files now partial-mock with Cutover script — closed.
Also closed: One still open: the Discord gate. Two smaller things: The warning is still unbounded — The body's numbers are behind the branch: it says 63 files / 1099 tests and "reverting the function body fails 12". At head it's 66 files / 1234 tests, and that revert fails 29 — the coverage is better than the description claims.
|
Recording: the flag under nine different spellings — captured from this branch's
isShadowMode()actually running, ending on the log line it emits. Linked rather than embedded:outpostis private, soraw.githubusercontent.comreturns 404 to GitHub's own image proxy and an inline![]()renders broken. See What is not covered.The problem
Shadow mode is the flag that lets Outpost run alongside an incumbent without double-posting at real reporters. With it on, answers are generated, scored and recorded, but never posted to Discord or GitHub. It is the one switch standing between a parallel-run validation window and machine-generated text arriving in a stranger's support thread.
Every place that read it tested
process.env.SHADOW_MODE === 'true'. So an operator who setsSHADOW_MODE=TRUE, or=1, or=yes— three spellings any of us would write without thinking — gets a worker that reads "not shadow mode" and posts for real. There is no error, no warning, and nothing in the logs to distinguish it from a deliberate live run. The first evidence is a reply on a community thread.The codebase already conceded the ambiguity in its own docblock, which describes the behaviour as "When SHADOW_MODE=true" while the comparison it documents accepts exactly that one spelling and silently rejects the rest. And there were three separate copies of the predicate — in the queue's AI handler, in the onboarding digest, and in the Discord bot's helper — all three with the same comparison. A safety flag failing open, in triplicate.
The approach
One
isShadowMode()inshared, and it fails closed. Recognized off values (false,0,no,off, empty) are off. Recognized on values (true,1,yes,on) are on. Anything else that is set is treated as on, and logged with the value it rejected.The asymmetry is the whole design, and it is not free. A false positive costs a parallel-run window where nothing gets posted and somebody works it out from a warning in the logs. A false negative posts generated text at real people under the flag that existed to prevent that. Those are not comparable, so the tie is broken toward silence — which does mean a typo in
SHADOW_MODEnow quietly stops the bot answering, and you find that out by reading logs rather than by watching behaviour. That is the trade, and it is the right way round.Unset still means off. Shadow mode is opt-in; defaulting an absent variable to on would make a fresh deployment silently answer nobody, which is a worse failure than the one being fixed. The fail-closed rule applies only to values that are set — those represent an operator trying to say something, and the safe reading of an unclear instruction is the one that posts nothing.
Central, not per-call-site, as #157 suggested. Three copies existed and all three had the identical bug, which is precisely why it survived this long; the Discord bot's helper now re-exports the shared one instead of keeping its own. Values are trimmed and case-folded before comparison, so
" True "behaves liketrue.What is not covered
SHADOW_MODE. The=== 'true'pattern appears on other boolean env vars in this repo. Those are not safety flags, so they are not urgent, and folding them in would make this diff about something else.@copilotkit/outpost/sharedwholesale, so the mock had to grow anisShadowModeentry. It delegates to the same env check rather than returning a constant — a hardcodedfalsewould have left those files' existingSHADOW_MODEtests asserting nothing. That is duplicated logic in test scaffolding, and worth knowing about.pr-mediabranch so the diff here stays pure code.mainrather than assuming: the null-description-stringifies-as-"null"concatenation and theescalated: true-when-the-enqueue-threw report were both closed by feat(queue): one AI answer per ticket, arbitrated by the database #191. AI response handler: three small correctness gaps (escalation reporting, null description, SHADOW_MODE compare) #157 can be closed when this lands.responseStatedoes not supply the marker Withheld drafts are indistinguishable from delivered replies (replayed as assistant history, no DB marker) #148 needs. A delivered low-confidence answer also ends upESCALATED, soESCALATEDcannot mean "the reporter never saw this". Withheld drafts are indistinguishable from delivered replies (replayed as assistant history, no DB marker) #148 still needs its ownsuppressedcolumn.Verification
packages/outpost: 63 files, 1099 tests passing, against 1075 onmain— the delta is exactly the new file and nothing changed status.apps/discord-botunchanged at 66. Typecheck: 48 errors inqueue, 0 inshared, identical tomain(the 48 are the known unbuilt-workspace resolution errors, which CI'spnpm buildstep resolves). Prettier clean on every changed file.shared/src/__tests__/shadow-mode.test.ts— 24 tests. Holds down the nine previously-fail-open spellings, the explicit off values, unset meaning off, and that an unrecognized value is both honoured as on and reported with the value in the message. Reverting the function body to=== 'true'fails 12 of them.Merge notes
shared/src/index.tsgains one export line, andqueue/src/handlers/ai-response.tsgains one symbol on an existing import. #150 (feat/slack-ticket-mirror-impl) also editsai-response.tsand was just brought up to date withmain, so expect a one-line import conflict there depending on which lands first.