(motir-core) The acceptance-lane lifecycle guard has NEVER checked a receipt in CI — no job sets its credential, so MOTIR-2770 always passes and the lane has grown back to 23 specs
Opened by Zhu Yue ·
Found 2026-09-01 while asking why main runs the acceptance lane on every merge. It does, because the guard that was supposed to empty the lane has been a no-op since it shipped.
⚠️ AMENDED (1) 2026-09-01 by a halted
motir run(run.md guard #4) — read this before AC 1The finding below is correct and reconfirmed on
origin/mainate5346ff9c. Two things the card assumed underneath it are false, and both were verified against shipped reality rather than reasoned about:
- There is no read path to spend a credential on.
fetchApprovedStoriesissuesGET {base}/api/work-items/{key}/acceptance-evidence. That path exports onlyPOST; no GET handler exists anywhere in the tree,git log --all -S'export async function GET'on it returns nothing (it has never existed), and the DEPLOYED product answers HTTP 405 to a real bearer. Because the function fails OPEN by design, 405 is indistinguishable from "no receipt is approved" — so the approved set is empty on every call whatever credential is supplied. This card is nowblocked_bythe sibling that builds that route. — DISCHARGED: MOTIR-4144 merged as #2515 and the route now exists.MOTIR_UPLOAD_TOKENis not a configured secret.gh secret listonmoooon-B-V/motir-corereturns ten secrets and it is not among them. The publish step's working credential is keyless GitHub OIDC (MOTIR-1650); the secret is an unconfigured PAT fallback. So AC 1's "the same secret/vars the publish step used" names an empty string, and shipping AC 1 + AC 2 together as written would turnmainred on every merge while still measuring nothing.AC 1 is therefore re-pointed (below) at whatever credential the blocking card settles on. AC 4 is discharged: MOTIR-4094 merged as #2500 at
2026-09-01T20:53:57Zand drained the lane, so the count is now 0, not 23 —git ls-tree -r --name-only origin/main tests/e2e/ | grep -E '^tests/e2e/acceptance[^/]*\.spec\.ts$' | wc -l. Sizing moved 2→3 points / 40→60 minutes for AC 3's workflow predicate, which is a new guard file rather than anenv:edit.
⚠️ AMENDED (2) 2026-09-02 by a second halted
motir run(run.md guard #4) — THE REPLACEMENT CREDENTIAL IS NOT CONFIGURED EITHERAmendment (1) retired
MOTIR_UPLOAD_TOKENfor not being a configured secret, and the 2026-09-02 11:58 handover comment re-pointed AC 1 atMOTIR_GUARD_TOKEN. Checked against the PROVIDER this time, onorigin/mainatcd236496d:
gh secret list --repo moooon-B-V/motir-core→ 12 repository secrets (BETTER_AUTH_SECRET,FLY_API_TOKEN,NEXT_PUBLIC_SENTRY_DSN,NEXT_SERVER_ACTIONS_ENCRYPTION_KEY,NPM_TOKEN,RELEASE_APP_ID,RELEASE_APP_PRIVATE_KEY,SEED_DATABASE_URL,SENTRY_AUTH_TOKEN,SENTRY_ORG,SENTRY_PROJECT,VERCEL_TOKEN).MOTIR_GUARD_TOKENis not among them.gh api repos/moooon-B-V/motir-core/actions/organization-secrets→total_count: 0. Both environments (Preview,Production) →total_count: 0secrets each. There is nowhere else it could be.gh api repos/moooon-B-V/motir-core/actions/variables→ one variable,FLY_EXPECTED_MACHINE_COUNT.MOTIR_BASE_URLis not a variable, and after MOTIR-4096 retired the publisher the stringMOTIR_BASE_URLappears nowhere in.github/**— so the handover comment's "alreadyvars.MOTIR_BASE_URL || 'https://app.motir.co'in the acceptance lane" is stale. Harmless: the literal fallback can simply be written fresh. The TOKEN half is not harmless.So this card is now
blocked_byamanual/humansibling that mints and stores the credential. Wiring an empty token and shipping AC 2's fail-closed assertion in the same pull request makesci.yml'stestjob — twelve legs, on every pull request and every merge-queue entry — fail on a value no pull request can supply: a repository-wide merge outage, and exactly the outcome amendment (1) predicted for the previous credential.The keyless arm was considered and rejected.
MOTIR_GUARD_AUTH=github-oidcneeds no secret and MOTIR-4144 tested it — butauthenticateGithubOidc(lib/github/oidcAuth.ts) returns the workspace OWNER's identity andauthenticateCiPublisherthen skips the permission check entirely, soid-token: writeon the twelve-leg Vitest job would let any file in the suite, on any non-fork pull request, authenticate to production Motir as the workspace owner. A PAT scoped toproject:browseis both smaller and the arm the handover comment names.One more state to re-check before building, not a blocker: at
2026-09-02T14:33Zthe DEPLOYED origin still answered 405 toGET https://app.motir.co/api/work-items/MOTIR-1627/acceptance-evidencewith a real bearer — MOTIR-4144 merged at14:21:53Zand the deploy ofcd236496dwas still in flight. Since MOTIR-4144 made a route-level status THROW (LaneGuardReadError) instead of folding into "not approved", a credentialled guard pointed at an origin without the route is now RED rather than silently green. Re-run that curl and read 200 before wiring anything.
The mechanism
tests/e2e-acceptance-lane-membership.test.ts is MOTIR-2770's guard: no spec in the lane may have an approved receipt, and every member must declare its story. Its functions now live in tests/helpers/acceptanceLaneGuard.ts (MOTIR-4144 — one implementation, two callers; the route test needs a database and a spec cannot import a spec). The receipt half needs to ask the product:
export function resolveStatusSource(env: Record<string, string | undefined>): StatusSource | null {
const baseUrl = env['MOTIR_GUARD_BASE_URL'] ?? env['MOTIR_BASE_URL'] ?? '';
const token = env['MOTIR_GUARD_TOKEN'] ?? env['MOTIR_UPLOAD_TOKEN'] ?? '';
const authMode = env['MOTIR_GUARD_AUTH'] === 'github-oidc' ? 'github-oidc' : 'bearer';
return baseUrl && token ? { baseUrl: baseUrl.replace(/\/$/, ''), token, authMode } : null;
}
With no source it checks only the undeclared-story half and prints its own degradation notice, which ends:
(The approved-receipt half of this guard was SKIPPED: no
MOTIR_GUARD_TOKEN/MOTIR_UPLOAD_TOKEN+MOTIR_BASE_URLin this environment, so the product could not be asked which receipts are approved. It runs in CI.)
It does not run in CI. On origin/main at cd236496d those names appear in no workflow file at all — MOTIR-4096 retired the publish step that was their last home. The Vitest lane that executes this test (ci.yml's test job, 12 legs, vitest.collect.config.ts) sets none of them, so the guard has taken the degraded branch on every run it has ever had, and reported green.
And note the second degradation nobody has to reach a credential to hit: with the lane at 0 members the spec returns at members.length === 0 before resolveStatusSource is ever called, so an empty lane passes with no source and no notice. Whatever AC 2 asserts has to bind independently of lane membership, or the hatch is shut only on the days it would not have mattered.
What it cost
find tests/e2e -maxdepth 1 -name 'acceptance*.spec.ts' | wc -l on origin/main = 23 at filing time — now 0, drained by MOTIR-4094 (see amendment (1)). The workflow header states the opposite as settled fact, and every cost argument in the file is priced on it:
- "after the MOTIR-2769 triage the lane's steady-state membership is ZERO"
- MOTIR-2760's gate: "an empty lane costs one ~10s checkout per merge"
- MOTIR-2908's derived fan-out:
min(specs, 4)— at 23 members that is permanently the capped 4.
So every merge paid build + 4 shards instead of a 10-second gate. Measured on run 33502392371 (merge of #2495): gate 0.35 + build 3.8 + shards 13.2 / 13.3 / 10.9 / 8.1 = ~50 machine-minutes, ~17 min wall, on ~20 executed baselines a day. The drain has since removed the immediate cost; the HOLE this card closes is what stops it coming back.
Acceptance criteria
- The job that runs
tests/e2e-acceptance-lane-membership.test.ts—ci.yml'stestjob — has BOTH an origin and a token in itsenv::MOTIR_GUARD_TOKEN: ${{ secrets.MOTIR_GUARD_TOKEN }}andMOTIR_BASE_URL: https://app.motir.co(a literal — there is novars.MOTIR_BASE_URLand no other workflow left to copy an expression from). ⚠️ AMENDED TWICE. The original wording namedsecrets.MOTIR_UPLOAD_TOKEN; amendment (1) retired it as unconfigured;MOTIR_GUARD_TOKENis unconfigured too, so this criterion is discharged only once its provisioning sibling isdone— that card is this one'sblocked_by, and its AC 3 records the 200 that proves the value behind the name. Prefer the guard-specificMOTIR_GUARD_*names overMOTIR_BASE_URLwhere a choice exists, so a job-level env cannot change what any other test in the lane sees. - The guard refuses to pass silently where it is meant to bind. A test asserts that in an environment that is SUPPOSED to bind and has no credential resolvable, the run FAILS instead of printing the degradation notice — and that assertion does not depend on the lane having members, since the spec returns early at
members.length === 0and the lane's steady state is 0. The laptop / fork-PR degradation stays exactly as it is — that is the stated design, and it is only the CI claim that was false. Note the discriminator cannot beCIalone: a fork PR also setsCIand gets no secrets, so the workflow has to say which environment is SUPPOSED to bind (e.g. required unlessgithub.event.pull_request.head.repo.fork). - Self-recounting predicate, not a count: no job in
.github/workflows/**runs this test file without a resolvable source. A new job that reintroduces the hole fails the assertion. Model it ontests/ci-job-timeouts.test.ts, which derives its subjects from the directory rather than listing them; keep every helper INLINE in the spec, because a new non-spec module undertests/that callsreaddirSyncbecomes a "scanner module" and drags its importers intotests/ci-structural-guards-lane.test.ts's candidate set. The PR body carries the guard's real output against the lane as it stands — each member spec with its story key and whether that receipt is approved. That list is the input MOTIR-4094 works from.DISCHARGED: MOTIR-4094 isdoneand merged (#2500), and the lane it drained now holds 0 specs, so there is no disposition list left to produce and no consumer waiting for one.
Sequencing — read this before starting
Landing this fix RED-LIGHTS the lane immediately for every discharged spec, and MOTIR-4094 has landed, so that hazard is gone. main stays red until the triage lands. So this card is blocked_by MOTIR-4094: drain first, then close the hole behind it.The card is now blocked on the missing read path instead — MOTIR-4144 has landed too (#2515).
What remains is the CREDENTIAL, and it is the last blocker. The order is forced: mint and store MOTIR_GUARD_TOKEN, prove it reads 200 against the deployed origin, and only then wire the env: and the fail-closed assertion. Reversing it turns the twelve-leg Vitest lane red on every pull request, on a value no pull request can supply.
Context refs
tests/helpers/acceptanceLaneGuard.ts—resolveStatusSource,fetchApprovedStories,LaneGuardReadError,judgeLane(moved here by MOTIR-4144).tests/e2e-acceptance-lane-membership.test.ts— the check itself and its twodescribeblocks; still where the guard's own tests live..github/workflows/ci.yml— thetestjob'senv:block, where the credential lands. Note itsif:—needs.changes.outputs.app == 'true' && github.event_name != 'push'— so it runs on pull requests and the merge queue, never on push-to-main.tests/ci-job-timeouts.test.ts— the self-recounting whole-directory workflow guard to model AC 3 on.tests/helpers/structuralGuardLane.ts—BOUNDED_SCAN_MODULESalready declaresacceptanceLaneGuard.ts; read it before adding a helper module that walks a directory.docs/decisions/acceptance-receipt-lifecycle.md§3 — the lifecycle this guard enforces.MOTIR-4096 retires the publish step and with it today's onlyMOTIR-4096 landed first, and took every one of these variable names out ofMOTIR_UPLOAD_TOKENconsumer: whichever of the two lands second must leave the guard's credential wired..github/**. There is nothing left to preserve — this card writes them fresh.
Discussion
No comments yet.
Adding to this discussion signs you in on app.motir.co and brings you back to this request.